From d0554a3f670f25a1bbfae7cac1c5620fdae8e216 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Wed, 8 Apr 2026 14:05:41 +0200 Subject: [PATCH 01/11] refactor: cross platform worker preparation --- package-lock.json | 9 + packages/alphatab/src/Environment.ts | 28 +-- packages/alphatab/src/model/JsonConverter.ts | 2 +- .../AlphaSynthAudioWorkletOutput.ts | 6 +- .../platform/javascript/BrowserUiFacade.ts | 6 +- .../src/platform/javascript/IWorkerScope.ts | 8 - .../AlphaSynthAudioExporterWorkerApi.ts | 10 +- .../AlphaSynthWebWorker.ts | 143 +++++++--------- .../AlphaSynthWebWorkerApi.ts | 71 ++++---- .../AlphaSynthWorkerSynthOutput.ts | 35 ++-- .../AlphaTabWebWorker.ts | 45 +++-- .../platform/worker/AlphaTabWorkerProtocol.ts | 135 +++++++++++++++ .../AlphaTabWorkerScoreRenderer.ts | 23 +-- .../src/rendering/utils/BoundsLookup.ts | 161 +++++++++--------- packages/alphatab/src/synth/_barrel.ts | 2 +- 15 files changed, 398 insertions(+), 286 deletions(-) delete mode 100644 packages/alphatab/src/platform/javascript/IWorkerScope.ts rename packages/alphatab/src/platform/{javascript => worker}/AlphaSynthAudioExporterWorkerApi.ts (93%) rename packages/alphatab/src/platform/{javascript => worker}/AlphaSynthWebWorker.ts (68%) rename packages/alphatab/src/platform/{javascript => worker}/AlphaSynthWebWorkerApi.ts (92%) rename packages/alphatab/src/platform/{javascript => worker}/AlphaSynthWorkerSynthOutput.ts (82%) rename packages/alphatab/src/platform/{javascript => worker}/AlphaTabWebWorker.ts (80%) create mode 100644 packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts rename packages/alphatab/src/platform/{javascript => worker}/AlphaTabWorkerScoreRenderer.ts (88%) diff --git a/package-lock.json b/package-lock.json index 4cacaba87..3df881617 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "version": "7.28.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -1203,6 +1204,7 @@ "node_modules/@popperjs/core": { "version": "2.11.8", "license": "MIT", + "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/popperjs" @@ -1827,6 +1829,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1857,6 +1860,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2145,6 +2149,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5589,6 +5594,7 @@ "node_modules/rollup": { "version": "4.50.0", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6450,6 +6456,7 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6553,6 +6560,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6800,6 +6808,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.105.4.tgz", "integrity": "sha512-jTywjboN9aHxFlToqb0K0Zs9SbBoW4zRUlGzI2tYNxVYcEi/IPpn+Xi4ye5jTLvX2YeLuic/IvxNot+Q1jMoOw==", "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", diff --git a/packages/alphatab/src/Environment.ts b/packages/alphatab/src/Environment.ts index 6eb7d74cb..4e97bb9db 100644 --- a/packages/alphatab/src/Environment.ts +++ b/packages/alphatab/src/Environment.ts @@ -14,8 +14,8 @@ import { GolpeType } from '@coderline/alphatab/model/GolpeType'; import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { AlphaSynthWebWorklet } from '@coderline/alphatab/platform/javascript/AlphaSynthAudioWorkletOutput'; -import { AlphaSynthWebWorker } from '@coderline/alphatab/platform/javascript/AlphaSynthWebWorker'; -import { AlphaTabWebWorker } from '@coderline/alphatab/platform/javascript/AlphaTabWebWorker'; +import { AlphaSynthWebWorker } from '@coderline/alphatab/platform/worker/AlphaSynthWebWorker'; +import { AlphaTabWebWorker } from '@coderline/alphatab/platform/worker/AlphaTabWebWorker'; import { Html5Canvas } from '@coderline/alphatab/platform/javascript/Html5Canvas'; import { JQueryAlphaTab } from '@coderline/alphatab/platform/javascript/JQueryAlphaTab'; import { WebPlatform } from '@coderline/alphatab/platform/javascript/WebPlatform'; @@ -76,6 +76,7 @@ import { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; import { TabBarRendererFactory } from '@coderline/alphatab/rendering/TabBarRendererFactory'; import type { Settings } from '@coderline/alphatab/Settings'; import { StaveProfile } from '@coderline/alphatab/StaveProfile'; +import type { AlphaSynthWorker, AlphaTabWorker } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; /** * A factory for custom layout engines. @@ -207,16 +208,19 @@ export class Environment { } /** - * @target web * @internal */ - public static createWebWorker: (settings: Settings) => Worker; + public static createAlphaTabWebWorker: (settings: Settings) => AlphaTabWorker; + + /** + * @internal + */ + public static createAlphaSynthWebWorker: (settings: Settings) => AlphaSynthWorker; /** - * @target web * @internal */ - public static createAudioWorklet: (context: AudioContext, settings: Settings) => Promise; + public static createAlphaSynthAudioWorklet: (context: AudioContext, settings: Settings) => Promise; /** * @target web @@ -637,7 +641,7 @@ export class Environment { * @target web */ public static initializeMain( - createWebWorker: (settings: Settings) => Worker, + createWebWorker: (settings: Settings, nameHint: string) => Worker, createAudioWorklet: (context: AudioContext, settings: Settings) => Promise ) { if (Environment.isRunningInWorker || Environment.isRunningInAudioWorklet) { @@ -650,8 +654,9 @@ export class Environment { Environment.highDpiFactor = window.devicePixelRatio; } - Environment.createWebWorker = createWebWorker; - Environment.createAudioWorklet = createAudioWorklet; + Environment.createAlphaTabWebWorker = s => createWebWorker(s, 'alphaTab Renderer'); + Environment.createAlphaSynthWebWorker = s => createWebWorker(s, 'alphaSynth Worker'); + Environment.createAlphaSynthAudioWorklet = createAudioWorklet; } /** @@ -682,7 +687,10 @@ export class Environment { } AlphaTabWebWorker.init(); AlphaSynthWebWorker.init(); - Environment.createWebWorker = _ => { + Environment.createAlphaTabWebWorker = _ => { + throw new AlphaTabError(AlphaTabErrorType.General, 'Nested workers are not supported'); + }; + Environment.createAlphaSynthWebWorker = _ => { throw new AlphaTabError(AlphaTabErrorType.General, 'Nested workers are not supported'); }; } diff --git a/packages/alphatab/src/model/JsonConverter.ts b/packages/alphatab/src/model/JsonConverter.ts index 611961a22..c1fec2d99 100644 --- a/packages/alphatab/src/model/JsonConverter.ts +++ b/packages/alphatab/src/model/JsonConverter.ts @@ -76,7 +76,7 @@ export class JsonConverter { * @param score The score object to serialize * @returns A serialized score object without ciruclar dependencies that can be used for further serializations. */ - public static scoreToJsObject(score: Score): unknown { + public static scoreToJsObject(score: Score): Map|null { return ScoreSerializer.toJson(score); } diff --git a/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts b/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts index b69d2975a..bc5de0060 100644 --- a/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts +++ b/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts @@ -1,7 +1,7 @@ import { CircularSampleBuffer } from '@coderline/alphatab/synth/ds/CircularSampleBuffer'; import { Environment } from '@coderline/alphatab/Environment'; import { Logger } from '@coderline/alphatab/Logger'; -import { AlphaSynthWorkerSynthOutput } from '@coderline/alphatab/platform/javascript/AlphaSynthWorkerSynthOutput'; +import { AlphaSynthWorkerSynthOutput } from '@coderline/alphatab/platform/worker/AlphaSynthWorkerSynthOutput'; import { AlphaSynthWebAudioOutputBase } from '@coderline/alphatab/platform/javascript/AlphaSynthWebAudioOutputBase'; import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; import type { Settings } from '@coderline/alphatab/Settings'; @@ -198,7 +198,7 @@ export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase { super.play(); const ctx = this.context!; // create a script processor node which will replace the silence with the generated audio - Environment.createAudioWorklet(ctx, this._settings).then( + Environment.createAlphaSynthAudioWorklet(ctx, this._settings).then( () => { this._worklet = new AudioWorkletNode(ctx!, 'alphatab', { numberOfOutputs: 1, @@ -212,7 +212,7 @@ export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase { this.source!.start(0); this._worklet.connect(ctx!.destination); }, - reason => { + (reason:any) => { Logger.error('WebAudio', `Audio Worklet creation failed: reason=${reason}`); } ); diff --git a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts index faa89586d..edd9d4d17 100644 --- a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts +++ b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts @@ -18,9 +18,9 @@ import { Logger } from '@coderline/alphatab/Logger'; import type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs'; import type { IUiFacade } from '@coderline/alphatab/platform/IUiFacade'; import { AlphaSynthScriptProcessorOutput } from '@coderline/alphatab/platform/javascript/AlphaSynthScriptProcessorOutput'; -import { AlphaSynthWebWorkerApi } from '@coderline/alphatab/platform/javascript/AlphaSynthWebWorkerApi'; +import { AlphaSynthWebWorkerApi } from '@coderline/alphatab/platform/worker/AlphaSynthWebWorkerApi'; import type { AlphaTabApi } from '@coderline/alphatab/platform/javascript/AlphaTabApi'; -import { AlphaTabWorkerScoreRenderer } from '@coderline/alphatab/platform/javascript/AlphaTabWorkerScoreRenderer'; +import { AlphaTabWorkerScoreRenderer } from '@coderline/alphatab/platform/worker/AlphaTabWorkerScoreRenderer'; import type { BrowserMouseEventArgs } from '@coderline/alphatab/platform/javascript/BrowserMouseEventArgs'; import { Cursors } from '@coderline/alphatab/platform/Cursors'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; @@ -35,7 +35,7 @@ import { AudioElementBackingTrackSynthOutput } from '@coderline/alphatab/platfor import { BackingTrackPlayer } from '@coderline/alphatab/synth/BackingTrackPlayer'; import { CoreSettings, FontFileFormat } from '@coderline/alphatab/CoreSettings'; import type { IAudioExporterWorker } from '@coderline/alphatab/synth/IAudioExporter'; -import { AlphaSynthAudioExporterWorkerApi } from '@coderline/alphatab/platform/javascript/AlphaSynthAudioExporterWorkerApi'; +import { AlphaSynthAudioExporterWorkerApi } from '@coderline/alphatab/platform/worker/AlphaSynthAudioExporterWorkerApi'; /** * @target web diff --git a/packages/alphatab/src/platform/javascript/IWorkerScope.ts b/packages/alphatab/src/platform/javascript/IWorkerScope.ts deleted file mode 100644 index 8b33db253..000000000 --- a/packages/alphatab/src/platform/javascript/IWorkerScope.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @target web - * @internal - */ -export interface IWorkerScope { - addEventListener(eventType: string, listener: (e: MessageEvent) => void, capture?: boolean): void; - postMessage(message: unknown): void; -} diff --git a/packages/alphatab/src/platform/javascript/AlphaSynthAudioExporterWorkerApi.ts b/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts similarity index 93% rename from packages/alphatab/src/platform/javascript/AlphaSynthAudioExporterWorkerApi.ts rename to packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts index 8ae2ca72c..3da49bfe5 100644 --- a/packages/alphatab/src/platform/javascript/AlphaSynthAudioExporterWorkerApi.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts @@ -2,7 +2,8 @@ import { AlphaTabError, AlphaTabErrorType } from '@coderline/alphatab/AlphaTabEr import { Environment } from '@coderline/alphatab/Environment'; import type { MidiFile } from '@coderline/alphatab/midi/MidiFile'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; -import type { AlphaSynthWebWorkerApi } from '@coderline/alphatab/platform/javascript/AlphaSynthWebWorkerApi'; +import type { AlphaSynthWebWorkerApi } from '@coderline/alphatab/platform/worker/AlphaSynthWebWorkerApi'; +import type { IAlphaSynthWorkerMessage } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; import type { BackingTrackSyncPoint } from '@coderline/alphatab/synth/IAlphaSynth'; import type { AudioExportChunk, @@ -11,7 +12,6 @@ import type { } from '@coderline/alphatab/synth/IAudioExporter'; /** - * @target web * @internal */ export class AlphaSynthAudioExporterWorkerApi implements IAudioExporterWorker { @@ -35,10 +35,10 @@ export class AlphaSynthAudioExporterWorkerApi implements IAudioExporterWorker { syncPoints: BackingTrackSyncPoint[], transpositionPitches: Map ): Promise { - const onmessage = this.handleWorkerMessage.bind(this); - this._worker.worker.addEventListener('message', onmessage, false); + const onmessage: (ev: MessageEvent) => void = e => this.handleWorkerMessage(e); + this._worker.worker.addEventListener('message', onmessage); this._unsubscribe = () => { - this._worker.worker.removeEventListener('message', onmessage, false); + this._worker.worker.removeEventListener('message', onmessage); }; this._promise = Promise.withResolvers(); diff --git a/packages/alphatab/src/platform/javascript/AlphaSynthWebWorker.ts b/packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts similarity index 68% rename from packages/alphatab/src/platform/javascript/AlphaSynthWebWorker.ts rename to packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts index 26f26b464..1b19e33cb 100644 --- a/packages/alphatab/src/platform/javascript/AlphaSynthWebWorker.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts @@ -1,68 +1,63 @@ -import { AlphaSynth, type IAlphaSynthAudioExporter } from '@coderline/alphatab/synth/AlphaSynth'; -import type { PlayerStateChangedEventArgs } from '@coderline/alphatab/synth/PlayerStateChangedEventArgs'; -import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; -import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; -import { AlphaSynthWorkerSynthOutput } from '@coderline/alphatab/platform/javascript/AlphaSynthWorkerSynthOutput'; -import type { IWorkerScope } from '@coderline/alphatab/platform/javascript/IWorkerScope'; -import { Logger } from '@coderline/alphatab/Logger'; import { Environment } from '@coderline/alphatab/Environment'; +import { Logger } from '@coderline/alphatab/Logger'; +import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; +import { AlphaSynthWorkerSynthOutput } from '@coderline/alphatab/platform/worker/AlphaSynthWorkerSynthOutput'; +import type { + IAlphaSynthWorkerMessage, + IAlphaTabWorkerGlobalScope +} from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; +import { AlphaSynth, type IAlphaSynthAudioExporter } from '@coderline/alphatab/synth/AlphaSynth'; import type { MidiEventsPlayedEventArgs } from '@coderline/alphatab/synth/MidiEventsPlayedEventArgs'; import type { PlaybackRangeChangedEventArgs } from '@coderline/alphatab/synth/PlaybackRangeChangedEventArgs'; +import type { PlayerStateChangedEventArgs } from '@coderline/alphatab/synth/PlayerStateChangedEventArgs'; +import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; /** * This class implements a HTML5 WebWorker based version of alphaSynth * which can be controlled via WebWorker messages. - * @target web * @internal + * @partial */ export class AlphaSynthWebWorker { - private _player: AlphaSynth; - private _main: IWorkerScope; + private _player!: AlphaSynth; + private _main: IAlphaTabWorkerGlobalScope; private _exporter: Map = new Map(); - public constructor(main: IWorkerScope, bufferTimeInMilliseconds: number) { + public constructor(main: IAlphaTabWorkerGlobalScope) { this._main = main; - this._main.addEventListener('message', this.handleMessage.bind(this)); - - this._player = new AlphaSynth(new AlphaSynthWorkerSynthOutput(), bufferTimeInMilliseconds); - this._player.positionChanged.on(this.onPositionChanged.bind(this)); - this._player.stateChanged.on(this.onPlayerStateChanged.bind(this)); - this._player.finished.on(this.onFinished.bind(this)); - this._player.soundFontLoaded.on(this.onSoundFontLoaded.bind(this)); - this._player.soundFontLoadFailed.on(this.onSoundFontLoadFailed.bind(this)); - this._player.soundFontLoadFailed.on(this.onSoundFontLoadFailed.bind(this)); - this._player.midiLoaded.on(this.onMidiLoaded.bind(this)); - this._player.midiLoadFailed.on(this.onMidiLoadFailed.bind(this)); - this._player.readyForPlayback.on(this.onReadyForPlayback.bind(this)); - this._player.midiEventsPlayed.on(this.onMidiEventsPlayed.bind(this)); - this._player.playbackRangeChanged.on(this.onPlaybackRangeChanged.bind(this)); - this._main.postMessage({ - cmd: 'alphaSynth.ready' - }); + main.addEventListener('message', e => this.handleMessage(e)); } + /** + * @target web + * @partial + */ public static init(): void { - const main: IWorkerScope = Environment.globalThis as IWorkerScope; - main.addEventListener('message', e => { - const data: any = e.data; - const cmd: string = data.cmd; - switch (cmd) { - case 'alphaSynth.initialize': - AlphaSynthWorkerSynthOutput.preferredSampleRate = data.sampleRate; - Logger.logLevel = data.logLevel; - Environment.globalThis.alphaSynthWebWorker = new AlphaSynthWebWorker( - main, - data.bufferTimeInMilliseconds - ); - break; - } - }); + new AlphaSynthWebWorker(Environment.globalThis); } - public handleMessage(e: MessageEvent): void { - const data: any = e.data; - const cmd: string = data.cmd; - switch (cmd) { + public handleMessage(e: MessageEvent): void { + const data = e.data; + switch (data.cmd) { + case 'alphaSynth.initialize': + AlphaSynthWorkerSynthOutput.preferredSampleRate = data.sampleRate; + Logger.logLevel = data.logLevel; + this._player = new AlphaSynth(new AlphaSynthWorkerSynthOutput(), data.bufferTimeInMilliseconds); + this._player.positionChanged.on(e => this.onPositionChanged(e)); + this._player.stateChanged.on(e => this.onPlayerStateChanged(e)); + this._player.finished.on(() => this.onFinished()); + this._player.soundFontLoaded.on(() => this.onSoundFontLoaded()); + this._player.soundFontLoadFailed.on(e => this.onSoundFontLoadFailed(e)); + this._player.midiLoaded.on(e => this.onMidiLoaded(e)); + this._player.midiLoadFailed.on(e => this.onMidiLoadFailed(e)); + this._player.readyForPlayback.on(() => this.onReadyForPlayback()); + this._player.midiEventsPlayed.on(e => this.onMidiEventsPlayed(e)); + this._player.playbackRangeChanged.on(e => this.onPlaybackRangeChanged(e)); + this._main.postMessage({ + cmd: 'alphaSynth.ready' + }); + + break; case 'alphaSynth.setLogLevel': Logger.logLevel = data.value; break; @@ -139,20 +134,22 @@ export class AlphaSynthWebWorker { }); break; case 'alphaSynth.applyTranspositionPitches': - this._player.applyTranspositionPitches(new Map(JSON.parse(data.transpositionPitches))); + this._player.applyTranspositionPitches(data.transpositionPitches); break; } - if (cmd.startsWith('alphaSynth.exporter')) { + if (data.cmd.startsWith('alphaSynth.exporter')) { this._handleExporterMessage(e); } } - private _handleExporterMessage(e: MessageEvent) { - const data: any = e.data; - const cmd: string = data.cmd; + private _handleExporterMessage(e: MessageEvent) { + const data = e.data; + const cmd = data.cmd; + let exporterId = 0; try { switch (cmd) { case 'alphaSynth.exporter.initialize': + exporterId = data.exporterId; const exporter = this._player.exportAudio( data.options, JsonConverter.jsObjectToMidiFile(data.midi), @@ -168,6 +165,7 @@ export class AlphaSynthWebWorker { break; case 'alphaSynth.exporter.render': + exporterId = data.exporterId; if (this._exporter.has(data.exporterId)) { const exporter = this._exporter.get(data.exporterId)!; const chunk = exporter.render(data.milliseconds); @@ -186,14 +184,15 @@ export class AlphaSynthWebWorker { break; case 'alphaSynth.exporter.destroy': + exporterId = data.exporterId; this._exporter.delete(data.exporterId); break; } } catch (e) { this._main.postMessage({ cmd: 'alphaSynth.exporter.error', - exporterId: data.exporterId, - error: e + exporterId: exporterId, + error: e as Error }); } } @@ -201,13 +200,7 @@ export class AlphaSynthWebWorker { public onPositionChanged(e: PositionChangedEventArgs): void { this._main.postMessage({ cmd: 'alphaSynth.positionChanged', - currentTime: e.currentTime, - endTime: e.endTime, - currentTick: e.currentTick, - endTick: e.endTick, - isSeek: e.isSeek, - originalTempo: e.originalTempo, - modifiedTempo: e.modifiedTempo + args: e }); } @@ -234,41 +227,21 @@ export class AlphaSynthWebWorker { public onSoundFontLoadFailed(e: any): void { this._main.postMessage({ cmd: 'alphaSynth.soundFontLoadFailed', - error: this._serializeException(Environment.prepareForPostMessage(e)) + error: e }); } - private _serializeException(e: any): unknown { - const error: any = JSON.parse(JSON.stringify(e)); - if (e.message) { - error.message = e.message; - } - if (e.stack) { - error.stack = e.stack; - } - if (e.constructor && e.constructor.name) { - error.type = e.constructor.name; - } - return error; - } - public onMidiLoaded(e: PositionChangedEventArgs): void { this._main.postMessage({ cmd: 'alphaSynth.midiLoaded', - currentTime: e.currentTime, - endTime: e.endTime, - currentTick: e.currentTick, - endTick: e.endTick, - isSeek: e.isSeek, - originalTempo: e.originalTempo, - modifiedTempo: e.modifiedTempo + args: e }); } public onMidiLoadFailed(e: any): void { this._main.postMessage({ - cmd: 'alphaSynth.midiLoaded', - error: this._serializeException(Environment.prepareForPostMessage(e)) + cmd: 'alphaSynth.midiLoadFailed', + error: e }); } diff --git a/packages/alphatab/src/platform/javascript/AlphaSynthWebWorkerApi.ts b/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts similarity index 92% rename from packages/alphatab/src/platform/javascript/AlphaSynthWebWorkerApi.ts rename to packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts index 3c7a48948..8169a54aa 100644 --- a/packages/alphatab/src/platform/javascript/AlphaSynthWebWorkerApi.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts @@ -1,30 +1,38 @@ +import { Environment } from '@coderline/alphatab/Environment'; +import { + EventEmitter, + EventEmitterOfT, + type IEventEmitter, + type IEventEmitterOfT +} from '@coderline/alphatab/EventEmitter'; +import { Logger } from '@coderline/alphatab/Logger'; +import type { LogLevel } from '@coderline/alphatab/LogLevel'; +import type { MidiEventType } from '@coderline/alphatab/midi/MidiEvent'; import type { MidiFile } from '@coderline/alphatab/midi/MidiFile'; +import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import type { Score } from '@coderline/alphatab/model/Score'; +import type { + AlphaSynthWorker, + IAlphaSynthWorkerMessage +} from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; +import type { Settings } from '@coderline/alphatab/Settings'; import type { BackingTrackSyncPoint, IAlphaSynth } from '@coderline/alphatab/synth/IAlphaSynth'; import type { ISynthOutput } from '@coderline/alphatab/synth/ISynthOutput'; +import { MidiEventsPlayedEventArgs } from '@coderline/alphatab/synth/MidiEventsPlayedEventArgs'; import type { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; +import { PlaybackRangeChangedEventArgs } from '@coderline/alphatab/synth/PlaybackRangeChangedEventArgs'; import { PlayerState } from '@coderline/alphatab/synth/PlayerState'; import { PlayerStateChangedEventArgs } from '@coderline/alphatab/synth/PlayerStateChangedEventArgs'; import { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; -import { EventEmitter, type IEventEmitter, type IEventEmitterOfT, EventEmitterOfT } from '@coderline/alphatab/EventEmitter'; -import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; -import { Logger } from '@coderline/alphatab/Logger'; -import type { LogLevel } from '@coderline/alphatab/LogLevel'; import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; -import { MidiEventsPlayedEventArgs } from '@coderline/alphatab/synth/MidiEventsPlayedEventArgs'; -import type { MidiEventType } from '@coderline/alphatab/midi/MidiEvent'; -import { Environment } from '@coderline/alphatab/Environment'; -import { PlaybackRangeChangedEventArgs } from '@coderline/alphatab/synth/PlaybackRangeChangedEventArgs'; -import type { Settings } from '@coderline/alphatab/Settings'; -import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; -import type { Score } from '@coderline/alphatab/model/Score'; /** * a WebWorker based alphaSynth which uses the given player as output. - * @target web * @internal */ export class AlphaSynthWebWorkerApi implements IAlphaSynth { - private _synth!: Worker; + private _synth!: AlphaSynthWorker; private _output: ISynthOutput; private _workerIsReadyForPlayback: boolean = false; private _workerIsReady: boolean = false; @@ -60,7 +68,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { return Logger.logLevel; } - public get worker(): Worker { + public get worker(): AlphaSynthWorker { return this._synth; } @@ -237,11 +245,11 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { this._output.sampleRequest.on(this.onOutputSampleRequest.bind(this)); this._output.open(settings.player.bufferTimeInMilliseconds); try { - this._synth = Environment.createWebWorker(settings); + this._synth = Environment.createAlphaSynthWebWorker(settings); } catch (e) { Logger.error('AlphaSynth', `Failed to create WebWorker: ${e}`); } - this._synth.addEventListener('message', this.handleWorkerMessage.bind(this), false); + this._synth.addEventListener('message', e => this.handleWorkerMessage(e)); this._synth.postMessage({ cmd: 'alphaSynth.initialize', sampleRate: this._output.sampleRate, @@ -319,9 +327,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { public applyTranspositionPitches(transpositionPitches: Map): void { this._synth.postMessage({ cmd: 'alphaSynth.applyTranspositionPitches', - transpositionPitches: JSON.stringify( - Array.from(Environment.prepareForPostMessage(transpositionPitches).entries()) - ) + transpositionPitches: Environment.prepareForPostMessage(transpositionPitches) }); } @@ -364,10 +370,9 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { }); } - public handleWorkerMessage(e: MessageEvent): void { - const data: any = e.data; - const cmd: string = data.cmd; - switch (cmd) { + public handleWorkerMessage(e: MessageEvent): void { + const data = e.data; + switch (data.cmd) { case 'alphaSynth.ready': this._workerIsReady = true; this._checkReady(); @@ -380,15 +385,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { this._checkReadyForPlayback(); break; case 'alphaSynth.positionChanged': - this._currentPosition = new PositionChangedEventArgs( - data.currentTime, - data.endTime, - data.currentTick, - data.endTick, - data.isSeek, - data.originalTempo, - data.modifiedTempo - ); + this._currentPosition = data.args; (this.positionChanged as EventEmitterOfT).trigger(this._currentPosition); break; case 'alphaSynth.midiEventsPlayed': @@ -419,15 +416,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { break; case 'alphaSynth.midiLoaded': this._checkReadyForPlayback(); - this._loadedMidiInfo = new PositionChangedEventArgs( - data.currentTime, - data.endTime, - data.currentTick, - data.endTick, - data.isSeek, - data.originalTempo, - data.modifiedTempo - ); + this._loadedMidiInfo = data.args; (this.midiLoaded as EventEmitterOfT).trigger(this._loadedMidiInfo); break; case 'alphaSynth.midiLoadFailed': diff --git a/packages/alphatab/src/platform/javascript/AlphaSynthWorkerSynthOutput.ts b/packages/alphatab/src/platform/worker/AlphaSynthWorkerSynthOutput.ts similarity index 82% rename from packages/alphatab/src/platform/javascript/AlphaSynthWorkerSynthOutput.ts rename to packages/alphatab/src/platform/worker/AlphaSynthWorkerSynthOutput.ts index edac8d5e7..6d13b4c4d 100644 --- a/packages/alphatab/src/platform/javascript/AlphaSynthWorkerSynthOutput.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWorkerSynthOutput.ts @@ -1,11 +1,18 @@ -import type { ISynthOutput, ISynthOutputDevice } from '@coderline/alphatab/synth/ISynthOutput'; -import { EventEmitter, type IEventEmitter, type IEventEmitterOfT, EventEmitterOfT } from '@coderline/alphatab/EventEmitter'; -import type { IWorkerScope } from '@coderline/alphatab/platform/javascript/IWorkerScope'; -import { Logger } from '@coderline/alphatab/Logger'; import { Environment } from '@coderline/alphatab/Environment'; +import { + EventEmitter, + EventEmitterOfT, + type IEventEmitter, + type IEventEmitterOfT +} from '@coderline/alphatab/EventEmitter'; +import { Logger } from '@coderline/alphatab/Logger'; +import type { + IAlphaSynthWorkerMessage, + IAlphaTabWorkerGlobalScope +} from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; +import type { ISynthOutput, ISynthOutputDevice } from '@coderline/alphatab/synth/ISynthOutput'; /** - * @target web * @internal */ export class AlphaSynthWorkerSynthOutput implements ISynthOutput { @@ -24,7 +31,7 @@ export class AlphaSynthWorkerSynthOutput implements ISynthOutput { // that also includes the alphaSynth library into the worker. public static preferredSampleRate: number = 0; - private _worker!: IWorkerScope; + private _main!: IAlphaTabWorkerGlobalScope; public get sampleRate(): number { return AlphaSynthWorkerSynthOutput.preferredSampleRate; @@ -32,13 +39,13 @@ export class AlphaSynthWorkerSynthOutput implements ISynthOutput { public open(): void { Logger.debug('AlphaSynth', 'Initializing synth worker'); - this._worker = Environment.globalThis as IWorkerScope; - this._worker.addEventListener('message', this._handleMessage.bind(this)); + this._main = Environment.globalThis; + this._main.addEventListener('message', this._handleMessage.bind(this)); (this.ready as EventEmitter).trigger(); } public destroy(): void { - this._worker.postMessage({ + this._main.postMessage({ cmd: 'alphaSynth.output.destroy' }); } @@ -61,26 +68,26 @@ export class AlphaSynthWorkerSynthOutput implements ISynthOutput { public readonly sampleRequest: IEventEmitter = new EventEmitter(); public addSamples(samples: Float32Array): void { - this._worker.postMessage({ + this._main.postMessage({ cmd: 'alphaSynth.output.addSamples', samples: Environment.prepareForPostMessage(samples) }); } public play(): void { - this._worker.postMessage({ + this._main.postMessage({ cmd: 'alphaSynth.output.play' }); } public pause(): void { - this._worker.postMessage({ + this._main.postMessage({ cmd: 'alphaSynth.output.pause' }); } public resetSamples(): void { - this._worker.postMessage({ + this._main.postMessage({ cmd: 'alphaSynth.output.resetSamples' }); } @@ -90,7 +97,7 @@ export class AlphaSynthWorkerSynthOutput implements ISynthOutput { } public async enumerateOutputDevices(): Promise { - return []; + return [] as ISynthOutputDevice[]; } public async setOutputDevice(_device: ISynthOutputDevice | null): Promise {} public async getOutputDevice(): Promise { diff --git a/packages/alphatab/src/platform/javascript/AlphaTabWebWorker.ts b/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts similarity index 80% rename from packages/alphatab/src/platform/javascript/AlphaTabWebWorker.ts rename to packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts index 62038c4df..2532d6950 100644 --- a/packages/alphatab/src/platform/javascript/AlphaTabWebWorker.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts @@ -3,35 +3,42 @@ import { SettingsSerializer } from '@coderline/alphatab/generated/SettingsSerial import { Logger } from '@coderline/alphatab/Logger'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import type { Score } from '@coderline/alphatab/model/Score'; -import type { IWorkerScope } from '@coderline/alphatab/platform/javascript/IWorkerScope'; import { type FontSizeDefinition, FontSizes } from '@coderline/alphatab/platform/svg/FontSizes'; +import type { + IAlphaTabWorkerGlobalScope, + IAlphaTabWorkerMessage +} from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; import type { RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; import { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; import type { Settings } from '@coderline/alphatab/Settings'; /** - * @target web * @public + * @partial */ export class AlphaTabWebWorker { private _renderer!: ScoreRenderer; - private _main: IWorkerScope; + private _main: IAlphaTabWorkerGlobalScope; - public constructor(main: IWorkerScope) { + public constructor(main: IAlphaTabWorkerGlobalScope) { this._main = main; - this._main.addEventListener('message', this._handleMessage.bind(this), false); + main.addEventListener('message', e => this._handleMessage(e)); } + /** + * @target web + * @partial + */ public static init(): void { - (Environment.globalThis as any).alphaTabWebWorker = new AlphaTabWebWorker( - Environment.globalThis as IWorkerScope - ); + new AlphaTabWebWorker(Environment.globalThis); } private _handleMessage(e: MessageEvent): void { - const data: any = e.data; - const cmd: any = data ? data.cmd : ''; - switch (cmd) { + const data = e.data as IAlphaTabWorkerMessage; + if (!data?.cmd) { + return; + } + switch (data.cmd) { case 'alphaTab.initialize': const settings: Settings = JsonConverter.jsObjectToSettings(data.settings); Logger.logLevel = settings.core.logLevel; @@ -92,19 +99,9 @@ export class AlphaTabWebWorker { } } - private _updateFontSizes(fontSizes: { [key: string]: FontSizeDefinition } | Map): void { - if (!(fontSizes instanceof Map)) { - const obj = fontSizes; - fontSizes = new Map(); - for (const font in obj) { - fontSizes.set(font, obj[font]); - } - } - - if (fontSizes) { - for (const [k, v] of fontSizes) { - FontSizes.fontSizeLookupTables.set(k, v); - } + private _updateFontSizes(fontSizes: Map): void { + for (const [k, v] of fontSizes) { + FontSizes.fontSizeLookupTables.set(k, v); } } diff --git a/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts b/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts new file mode 100644 index 000000000..b405e1149 --- /dev/null +++ b/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts @@ -0,0 +1,135 @@ +import type { LogLevel } from '@coderline/alphatab/LogLevel'; +import type { MidiEventType } from '@coderline/alphatab/midi/MidiEvent'; +import type { FontSizeDefinition } from '@coderline/alphatab/platform/_barrel'; +import type { RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; +import type { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; +import type { BackingTrackSyncPoint } from '@coderline/alphatab/synth/IAlphaSynth'; +import type { AudioExportChunk, AudioExportOptions } from '@coderline/alphatab/synth/IAudioExporter'; +import type { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; +import type { PlayerState } from '@coderline/alphatab/synth/PlayerState'; +import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; + +/** + * @internal + * @discriminated cmd alphaTab. + */ +export type IAlphaTabWorkerMessage = + // main -> worker + | { cmd: 'alphaTab.initialize'; settings: Map } + | { cmd: 'alphaTab.updateSettings'; settings: Map } + | { cmd: 'alphaTab.render'; renderHints: RenderHints | undefined } + | { cmd: 'alphaTab.resizeRender' } + | { cmd: 'alphaTab.renderResult'; resultId: string } + | { cmd: 'alphaTab.setWidth'; width: number } + | { + cmd: 'alphaTab.renderScore'; + score: Map | null; + trackIndexes: number[] | null; + fontSizes: Map; + renderHints: RenderHints | undefined; + } + // worker -> main + | { cmd: 'alphaTab.preRender'; resize: boolean } + | { cmd: 'alphaTab.partialRenderFinished'; result: RenderFinishedEventArgs } + | { cmd: 'alphaTab.partialLayoutFinished'; result: RenderFinishedEventArgs } + | { cmd: 'alphaTab.renderFinished'; result: RenderFinishedEventArgs } + | { cmd: 'alphaTab.postRenderFinished'; boundsLookup: Map | null } + | { cmd: 'alphaTab.error'; error: Error }; + +export interface IAlphaTabWorker { + postMessage(message: T): void; + addEventListener(event: 'message', handler: (ev: MessageEvent) => void): void; + removeEventListener(event: 'message', handler: (ev: MessageEvent) => void): void; + terminate(): void; +} + +export interface IAlphaTabWorkerGlobalScope { + postMessage(message: T): void; + addEventListener(event: 'message', handler: (ev: MessageEvent) => void): void; + removeEventListener(event: 'message', handler: (ev: MessageEvent) => void): void; +} + +/** + * @internal + * @discriminated cmd alphaSynth. + */ +export type IAlphaSynthWorkerMessage = + /* main -> worker */ + | { cmd: 'alphaSynth.initialize'; sampleRate: number; logLevel: LogLevel; bufferTimeInMilliseconds: number } + | { cmd: 'alphaSynth.setLogLevel'; value: LogLevel } + | { cmd: 'alphaSynth.setMasterVolume'; value: LogLevel } + | { cmd: 'alphaSynth.setMetronomeVolume'; value: LogLevel } + | { cmd: 'alphaSynth.setPlaybackSpeed'; value: LogLevel } + | { cmd: 'alphaSynth.setTickPosition'; value: LogLevel } + | { cmd: 'alphaSynth.setTimePosition'; value: LogLevel } + | { cmd: 'alphaSynth.setPlaybackRange'; value: PlaybackRange | null } + | { cmd: 'alphaSynth.setIsLooping'; value: boolean } + | { cmd: 'alphaSynth.setCountInVolume'; value: number } + | { cmd: 'alphaSynth.setMidiEventsPlayedFilter'; value: MidiEventType[] } + | { cmd: 'alphaSynth.play' } + | { cmd: 'alphaSynth.pause' } + | { cmd: 'alphaSynth.playPause' } + | { cmd: 'alphaSynth.stop' } + | { cmd: 'alphaSynth.playOneTimeMidiFile'; midi: unknown } + | { cmd: 'alphaSynth.loadSoundFontBytes'; data: Uint8Array; append: boolean } + | { cmd: 'alphaSynth.resetSoundFonts' } + | { cmd: 'alphaSynth.loadMidi'; midi: unknown } + | { cmd: 'alphaSynth.setChannelMute'; channel: number; mute: boolean } + | { cmd: 'alphaSynth.setChannelTranspositionPitch'; channel: number; semitones: number } + | { cmd: 'alphaSynth.setChannelSolo'; channel: number; solo: boolean } + | { cmd: 'alphaSynth.setChannelVolume'; channel: number; volume: number } + | { cmd: 'alphaSynth.resetChannelStates' } + | { cmd: 'alphaSynth.destroy' } + | { cmd: 'alphaSynth.applyTranspositionPitches'; transpositionPitches: Map } + /* worker -> main */ + | { cmd: 'alphaSynth.ready' } + | { cmd: 'alphaSynth.destroyed' } + | { + cmd: 'alphaSynth.positionChanged'; + args: PositionChangedEventArgs; + } + | { cmd: 'alphaSynth.playerStateChanged'; state: PlayerState; stopped: boolean } + | { cmd: 'alphaSynth.finished' } + | { cmd: 'alphaSynth.soundFontLoaded' } + | { cmd: 'alphaSynth.soundFontLoadFailed'; error: Error } + | { cmd: 'alphaSynth.midiLoaded'; args: PositionChangedEventArgs } + | { cmd: 'alphaSynth.midiLoadFailed'; error: Error } + | { cmd: 'alphaSynth.readyForPlayback' } + | { cmd: 'alphaSynth.midiEventsPlayed'; events: Map[] } + | { cmd: 'alphaSynth.playbackRangeChanged'; playbackRange: PlaybackRange | null } + + /* main -> exporter */ + | { + cmd: 'alphaSynth.exporter.initialize'; + options: AudioExportOptions; + midi: unknown; + syncPoints: BackingTrackSyncPoint[]; + transpositionPitches: Map; + exporterId: number; + } + | { cmd: 'alphaSynth.exporter.render'; exporterId: number; milliseconds: number } + | { cmd: 'alphaSynth.exporter.destroy'; exporterId: number } + /* exporter -> main */ + | { cmd: 'alphaSynth.exporter.initialized'; exporterId: number } + | { cmd: 'alphaSynth.exporter.rendered'; exporterId: number; chunk: AudioExportChunk | undefined } + | { cmd: 'alphaSynth.exporter.error'; exporterId: number; error: Error } + /* output -> worker */ + | { cmd: 'alphaSynth.output.sampleRequest' } + | { cmd: 'alphaSynth.output.samplesPlayed'; samples: number } + + /* worker -> output */ + | { cmd: 'alphaSynth.output.addSamples'; samples: Float32Array } + | { cmd: 'alphaSynth.output.play' } + | { cmd: 'alphaSynth.output.pause' } + | { cmd: 'alphaSynth.output.destroy' } + | { cmd: 'alphaSynth.output.resetSamples' }; + +/** + * @internal + */ +export interface AlphaTabWorker extends IAlphaTabWorker {} + +/** + * @internal + */ +export interface AlphaSynthWorker extends IAlphaTabWorker {} diff --git a/packages/alphatab/src/platform/javascript/AlphaTabWorkerScoreRenderer.ts b/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts similarity index 88% rename from packages/alphatab/src/platform/javascript/AlphaTabWorkerScoreRenderer.ts rename to packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts index 0ef29268e..fe782e42e 100644 --- a/packages/alphatab/src/platform/javascript/AlphaTabWorkerScoreRenderer.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts @@ -14,14 +14,17 @@ import { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup'; import type { Settings } from '@coderline/alphatab/Settings'; import { Logger } from '@coderline/alphatab/Logger'; import { Environment } from '@coderline/alphatab/Environment'; +import type { + AlphaTabWorker, + IAlphaTabWorkerMessage +} from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; /** - * @target web * @public */ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { private _api: AlphaTabApiBase; - private _worker!: Worker; + private _worker!: AlphaTabWorker; private _width: number = 0; public boundsLookup: BoundsLookup | null = null; @@ -30,7 +33,7 @@ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { this._api = api; try { - this._worker = Environment.createWebWorker(settings); + this._worker = Environment.createAlphaTabWebWorker(settings); } catch (e) { Logger.error('Rendering', `Failed to create WebWorker: ${e}`); return; @@ -39,7 +42,7 @@ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { cmd: 'alphaTab.initialize', settings: this._serializeSettingsForWorker(settings) }); - this._worker.addEventListener('message', this._handleWorkerMessage.bind(this)); + this._worker.addEventListener('message', e => this._handleWorkerMessage(e)); } public destroy(): void { @@ -53,7 +56,7 @@ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { }); } - private _serializeSettingsForWorker(settings: Settings): unknown { + private _serializeSettingsForWorker(settings: Settings): Map { const jsObject = JsonConverter.settingsToJsObject(Environment.prepareForPostMessage(settings))!; // cut out player settings, they are only needed on UI thread side jsObject.delete('player'); @@ -92,9 +95,9 @@ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { }); } - private _handleWorkerMessage(e: MessageEvent): void { - const data: any = e.data; - const cmd: string = data.cmd; + private _handleWorkerMessage(e: MessageEvent): void { + const data = e.data; + const cmd = data.cmd; switch (cmd) { case 'alphaTab.preRender': (this.preRender as EventEmitterOfT).trigger(data.resize); @@ -110,7 +113,7 @@ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { break; case 'alphaTab.postRenderFinished': this.boundsLookup = BoundsLookup.fromJson(data.boundsLookup, this._api.score!); - this.boundsLookup.finish(); + this.boundsLookup?.finish(); (this.postRenderFinished as EventEmitter).trigger(); break; case 'alphaTab.error': @@ -120,7 +123,7 @@ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { } public renderScore(score: Score | null, trackIndexes: number[] | null, renderHints?: RenderHints): void { - const jsObject: unknown = + const jsObject: Map | null = score == null ? null : JsonConverter.scoreToJsObject(Environment.prepareForPostMessage(score)); this._worker.postMessage({ cmd: 'alphaTab.renderScore', diff --git a/packages/alphatab/src/rendering/utils/BoundsLookup.ts b/packages/alphatab/src/rendering/utils/BoundsLookup.ts index 83788b4e9..97c324f21 100644 --- a/packages/alphatab/src/rendering/utils/BoundsLookup.ts +++ b/packages/alphatab/src/rendering/utils/BoundsLookup.ts @@ -13,105 +13,110 @@ import { StaffSystemBounds } from '@coderline/alphatab/rendering/utils/StaffSyst * @public */ export class BoundsLookup { - /** - * @target web - */ - public toJson(): unknown { - const json: any = {} as any; - const systems: StaffSystemBounds[] = []; - json.staffSystems = systems; + public toJson(): Map { + const json = new Map(); + const systems: Map[] = []; + json.set('staffSystems', systems); for (const system of this.staffSystems) { - const g: StaffSystemBounds = {} as any; - g.visualBounds = this._boundsToJson(system.visualBounds); - g.realBounds = this._boundsToJson(system.realBounds); - g.bars = []; + const g = new Map(); + g.set('visualBounds', BoundsLookup._boundsToJson(system.visualBounds)); + g.set('realBounds', BoundsLookup._boundsToJson(system.realBounds)); + const gBars: Map[] = []; + g.set('bars', gBars); + for (const masterBar of system.bars) { - const mb: MasterBarBounds = {} as any; - mb.lineAlignedBounds = this._boundsToJson(masterBar.lineAlignedBounds); - mb.visualBounds = this._boundsToJson(masterBar.visualBounds); - mb.realBounds = this._boundsToJson(masterBar.realBounds); - mb.index = masterBar.index; - mb.isFirstOfLine = masterBar.isFirstOfLine; - mb.bars = []; + const mb = new Map(); + mb.set('lineAlignedBounds', BoundsLookup._boundsToJson(masterBar.lineAlignedBounds)); + mb.set('visualBounds', BoundsLookup._boundsToJson(masterBar.visualBounds)); + mb.set('realBounds', BoundsLookup._boundsToJson(masterBar.realBounds)); + mb.set('index', masterBar.index); + mb.set('isFirstOfLine', masterBar.isFirstOfLine); + const mbBars: Map[] = []; + mb.set('bars', mbBars); for (const bar of masterBar.bars) { - const b: BarBounds = {} as any; - b.visualBounds = this._boundsToJson(bar.visualBounds); - b.realBounds = this._boundsToJson(bar.realBounds); - b.beats = []; + const b = new Map(); + b.set('visualBounds', BoundsLookup._boundsToJson(bar.visualBounds)); + b.set('realBounds', BoundsLookup._boundsToJson(bar.realBounds)); + const bBeats: Map[] = []; + b.set('beats', bBeats); for (const beat of bar.beats) { - const bb: BeatBounds = {} as any; - bb.visualBounds = this._boundsToJson(beat.visualBounds); - bb.realBounds = this._boundsToJson(beat.realBounds); - bb.onNotesX = beat.onNotesX; - const bbd: any = bb; - bbd.beatIndex = beat.beat.index; - bbd.voiceIndex = beat.beat.voice.index; - bbd.barIndex = beat.beat.voice.bar.index; - bbd.staffIndex = beat.beat.voice.bar.staff.index; - bbd.trackIndex = beat.beat.voice.bar.staff.track.index; + const bb = new Map(); + bb.set('visualBounds', BoundsLookup._boundsToJson(beat.visualBounds)); + bb.set('realBounds', BoundsLookup._boundsToJson(beat.realBounds)); + bb.set('onNotesX', beat.onNotesX); + bb.set('beatIndex', beat.beat.index); + bb.set('voiceIndex', beat.beat.voice.index); + bb.set('barIndex', beat.beat.voice.bar.index); + bb.set('staffIndex', beat.beat.voice.bar.staff.index); + bb.set('trackIndex', beat.beat.voice.bar.staff.track.index); if (beat.notes) { - const notes: NoteBounds[] = []; - bb.notes = notes; + const notes: Map[] = []; + bb.set('notes', notes); for (const note of beat.notes) { - const n: NoteBounds = {} as any; - const nd: any = n; - nd.index = note.note.index; - n.noteHeadBounds = this._boundsToJson(note.noteHeadBounds); + const n = new Map(); + n.set('index', note.note.index); + n.set('noteHeadBounds', BoundsLookup._boundsToJson(note.noteHeadBounds)); notes.push(n); } } - b.beats.push(bb); + bBeats.push(bb); } - mb.bars.push(b); + mbBars.push(b); } - g.bars.push(mb); + gBars.push(mb); } systems.push(g); } return json; } - /** - * @target web - */ - public static fromJson(json: unknown, score: Score): BoundsLookup { + public static fromJson(json: Map | null, score: Score): BoundsLookup | null { + if (json === null) { + return null; + } const lookup: BoundsLookup = new BoundsLookup(); - const staffSystems: StaffSystemBounds[] = (json as any).staffSystems; + const staffSystems = json.get('staffSystems')! as Map[]; for (const staffSystem of staffSystems) { const sg: StaffSystemBounds = new StaffSystemBounds(); - sg.visualBounds = BoundsLookup._boundsFromJson(staffSystem.visualBounds); - sg.realBounds = BoundsLookup._boundsFromJson(staffSystem.realBounds); + sg.visualBounds = BoundsLookup._boundsFromJson(staffSystem.get('visualBounds') as Map); + sg.realBounds = BoundsLookup._boundsFromJson(staffSystem.get('realBounds') as Map); lookup.addStaffSystem(sg); - for (const masterBar of staffSystem.bars) { + for (const masterBar of staffSystem.get('bars') as Map[]) { const mb: MasterBarBounds = new MasterBarBounds(); - mb.index = masterBar.index; - mb.isFirstOfLine = masterBar.isFirstOfLine; - mb.lineAlignedBounds = BoundsLookup._boundsFromJson(masterBar.lineAlignedBounds); - mb.visualBounds = BoundsLookup._boundsFromJson(masterBar.visualBounds); - mb.realBounds = BoundsLookup._boundsFromJson(masterBar.realBounds); + mb.index = masterBar.get('index') as number; + mb.isFirstOfLine = masterBar.get('isFirstOfLine') as boolean; + mb.lineAlignedBounds = BoundsLookup._boundsFromJson( + masterBar.get('lineAlignedBounds') as Map + ); + mb.visualBounds = BoundsLookup._boundsFromJson(masterBar.get('visualBounds') as Map); + mb.realBounds = BoundsLookup._boundsFromJson(masterBar.get('realBounds') as Map); lookup.addMasterBar(mb); - for (const bar of masterBar.bars) { + for (const bar of masterBar.get('bars') as Map[]) { const b: BarBounds = new BarBounds(); - b.visualBounds = BoundsLookup._boundsFromJson(bar.visualBounds); - b.realBounds = BoundsLookup._boundsFromJson(bar.realBounds); + b.visualBounds = BoundsLookup._boundsFromJson(bar.get('visualBounds') as Map); + b.realBounds = BoundsLookup._boundsFromJson(bar.get('realBounds') as Map); mb.addBar(b); - for (const beat of bar.beats) { + for (const beat of bar.get('beats') as Map[]) { const bb: BeatBounds = new BeatBounds(); - bb.visualBounds = BoundsLookup._boundsFromJson(beat.visualBounds); - bb.realBounds = BoundsLookup._boundsFromJson(beat.realBounds); - bb.onNotesX = beat.onNotesX; + bb.visualBounds = BoundsLookup._boundsFromJson( + beat.get('visualBounds') as Map + ); + bb.realBounds = BoundsLookup._boundsFromJson(beat.get('realBounds') as Map); + bb.onNotesX = beat.get('onNotesX') as number; const bd: any = beat; bb.beat = score.tracks[bd.trackIndex].staves[bd.staffIndex].bars[bd.barIndex].voices[ bd.voiceIndex ].beats[bd.beatIndex]; - if (beat.notes) { + if (beat.has('notes')) { bb.notes = []; - for (const note of beat.notes) { + for (const note of beat.get('notes') as Map[]) { const n: NoteBounds = new NoteBounds(); const nd: any = note; n.note = bb.beat.notes[nd.index]; - n.noteHeadBounds = BoundsLookup._boundsFromJson(note.noteHeadBounds); + n.noteHeadBounds = BoundsLookup._boundsFromJson( + note.get('noteHeadBounds') as Map + ); bb.addNote(n); } } @@ -123,27 +128,21 @@ export class BoundsLookup { return lookup; } - /** - * @target web - */ - private static _boundsFromJson(boundsRaw: Bounds): Bounds { + private static _boundsFromJson(boundsRaw: Map): Bounds { const b = new Bounds(); - b.x = boundsRaw.x; - b.y = boundsRaw.y; - b.w = boundsRaw.w; - b.h = boundsRaw.h; + b.x = boundsRaw.get('x') as number; + b.y = boundsRaw.get('y') as number; + b.w = boundsRaw.get('w') as number; + b.h = boundsRaw.get('h') as number; return b; } - /** - * @target web - */ - private _boundsToJson(bounds: Bounds): Bounds { - const json: Bounds = {} as any; - json.x = bounds.x; - json.y = bounds.y; - json.w = bounds.w; - json.h = bounds.h; + private static _boundsToJson(bounds: Bounds): Map { + const json = new Map(); + json.set('x', bounds.x); + json.set('y', bounds.y); + json.set('w', bounds.w); + json.set('h', bounds.h); return json; } diff --git a/packages/alphatab/src/synth/_barrel.ts b/packages/alphatab/src/synth/_barrel.ts index c4e2555b5..e5ec3a878 100644 --- a/packages/alphatab/src/synth/_barrel.ts +++ b/packages/alphatab/src/synth/_barrel.ts @@ -10,7 +10,7 @@ export { PlaybackRangeChangedEventArgs } from '@coderline/alphatab/synth/Playbac export { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; export { MidiEventsPlayedEventArgs } from '@coderline/alphatab/synth/MidiEventsPlayedEventArgs'; export { ActiveBeatsChangedEventArgs } from '@coderline/alphatab/synth/ActiveBeatsChangedEventArgs'; -export { AlphaSynthWebWorkerApi } from '@coderline/alphatab/platform/javascript/AlphaSynthWebWorkerApi'; +export { AlphaSynthWebWorkerApi } from '@coderline/alphatab/platform/worker/AlphaSynthWebWorkerApi'; export { AlphaSynthWebAudioOutputBase } from '@coderline/alphatab/platform/javascript/AlphaSynthWebAudioOutputBase'; export { AlphaSynthScriptProcessorOutput } from '@coderline/alphatab/platform/javascript/AlphaSynthScriptProcessorOutput'; export { AlphaSynthAudioWorkletOutput } from '@coderline/alphatab/platform/javascript/AlphaSynthAudioWorkletOutput'; From 4e212b480a180a2f70e40c9ecdc52ac88ac7a78f Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Wed, 8 Apr 2026 17:23:58 +0200 Subject: [PATCH 02/11] refactor: move worker creation and throttling to uifacade --- packages/alphatab/src/AlphaTabApiBase.ts | 2 +- packages/alphatab/src/Environment.ts | 57 +++++--------- packages/alphatab/src/platform/IUiFacade.ts | 17 ++++- .../AlphaSynthAudioWorkletOutput.ts | 75 ++++++++++++------- .../platform/javascript/BrowserUiFacade.ts | 60 ++++++++++++++- .../AlphaSynthAudioExporterWorkerApi.ts | 25 ++++--- .../platform/worker/AlphaSynthWebWorker.ts | 20 ++--- .../platform/worker/AlphaSynthWebWorkerApi.ts | 10 +-- .../worker/AlphaSynthWorkerSynthOutput.ts | 31 +++----- .../src/platform/worker/AlphaTabWebWorker.ts | 12 +-- .../platform/worker/AlphaTabWorkerProtocol.ts | 9 ++- .../worker/AlphaTabWorkerScoreRenderer.ts | 25 +++---- .../src/rendering/utils/BoundsLookup.ts | 12 +-- .../alphatab/test/visualTests/TestUiFacade.ts | 13 ++++ 14 files changed, 219 insertions(+), 149 deletions(-) diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 6efea4815..69a3138f6 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -395,7 +395,7 @@ export class AlphaTabApiBase { } this.container.resize.on( - Environment.throttle(() => { + this.uiFacade.throttle(() => { if (this._isDestroyed) { return; } diff --git a/packages/alphatab/src/Environment.ts b/packages/alphatab/src/Environment.ts index 4e97bb9db..e207a2db0 100644 --- a/packages/alphatab/src/Environment.ts +++ b/packages/alphatab/src/Environment.ts @@ -14,13 +14,17 @@ import { GolpeType } from '@coderline/alphatab/model/GolpeType'; import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; import type { ICanvas } from '@coderline/alphatab/platform/ICanvas'; import { AlphaSynthWebWorklet } from '@coderline/alphatab/platform/javascript/AlphaSynthAudioWorkletOutput'; -import { AlphaSynthWebWorker } from '@coderline/alphatab/platform/worker/AlphaSynthWebWorker'; -import { AlphaTabWebWorker } from '@coderline/alphatab/platform/worker/AlphaTabWebWorker'; +import { BrowserUiFacade } from '@coderline/alphatab/platform/javascript/BrowserUiFacade'; import { Html5Canvas } from '@coderline/alphatab/platform/javascript/Html5Canvas'; import { JQueryAlphaTab } from '@coderline/alphatab/platform/javascript/JQueryAlphaTab'; import { WebPlatform } from '@coderline/alphatab/platform/javascript/WebPlatform'; import { SkiaCanvas } from '@coderline/alphatab/platform/skia/SkiaCanvas'; import { CssFontSvgCanvas } from '@coderline/alphatab/platform/svg/CssFontSvgCanvas'; +import { AlphaSynthWebWorker } from '@coderline/alphatab/platform/worker/AlphaSynthWebWorker'; +import { AlphaTabWebWorker } from '@coderline/alphatab/platform/worker/AlphaTabWebWorker'; +import type { + IAlphaTabWorkerGlobalScope +} from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; import { EffectBandMode, type BarRendererFactory } from '@coderline/alphatab/rendering/BarRendererFactory'; import { AlternateEndingsEffectInfo } from '@coderline/alphatab/rendering/effects/AlternateEndingsEffectInfo'; import { BeatBarreEffectInfo } from '@coderline/alphatab/rendering/effects/BeatBarreEffectInfo'; @@ -76,7 +80,6 @@ import { TabBarRenderer } from '@coderline/alphatab/rendering/TabBarRenderer'; import { TabBarRendererFactory } from '@coderline/alphatab/rendering/TabBarRendererFactory'; import type { Settings } from '@coderline/alphatab/Settings'; import { StaveProfile } from '@coderline/alphatab/StaveProfile'; -import type { AlphaSynthWorker, AlphaTabWorker } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; /** * A factory for custom layout engines. @@ -168,6 +171,14 @@ export class Environment { return Environment._globalThis; } + /** + * @target web + * @internal + */ + public static getGlobalWorkerScope(): IAlphaTabWorkerGlobalScope { + return Environment.globalThis; + } + /** * @target web */ @@ -207,33 +218,6 @@ export class Environment { return 'AudioWorkletGlobalScope' in Environment.globalThis; } - /** - * @internal - */ - public static createAlphaTabWebWorker: (settings: Settings) => AlphaTabWorker; - - /** - * @internal - */ - public static createAlphaSynthWebWorker: (settings: Settings) => AlphaSynthWorker; - - /** - * @internal - */ - public static createAlphaSynthAudioWorklet: (context: AudioContext, settings: Settings) => Promise; - - /** - * @target web - * @partial - */ - public static throttle(action: () => void, delay: number): () => void { - let timeoutId: number = 0; - return () => { - Environment.globalThis.clearTimeout(timeoutId); - timeoutId = Environment.globalThis.setTimeout(action, delay); - }; - } - /** * @target web */ @@ -654,9 +638,9 @@ export class Environment { Environment.highDpiFactor = window.devicePixelRatio; } - Environment.createAlphaTabWebWorker = s => createWebWorker(s, 'alphaTab Renderer'); - Environment.createAlphaSynthWebWorker = s => createWebWorker(s, 'alphaSynth Worker'); - Environment.createAlphaSynthAudioWorklet = createAudioWorklet; + BrowserUiFacade.createAlphaTabWebWorker = s => createWebWorker(s, 'alphaTab Renderer'); + BrowserUiFacade.createAlphaSynthWebWorker = s => createWebWorker(s, 'alphaSynth Worker'); + BrowserUiFacade.createAlphaSynthAudioWorklet = createAudioWorklet; } /** @@ -687,12 +671,6 @@ export class Environment { } AlphaTabWebWorker.init(); AlphaSynthWebWorker.init(); - Environment.createAlphaTabWebWorker = _ => { - throw new AlphaTabError(AlphaTabErrorType.General, 'Nested workers are not supported'); - }; - Environment.createAlphaSynthWebWorker = _ => { - throw new AlphaTabError(AlphaTabErrorType.General, 'Nested workers are not supported'); - }; } /** @@ -836,6 +814,7 @@ export class Environment { * create proxy objects for all objects used. This code handles the necessary unwrapping. * @internal * @target web + * @partial */ public static prepareForPostMessage(object: T): T { if (!object) { diff --git a/packages/alphatab/src/platform/IUiFacade.ts b/packages/alphatab/src/platform/IUiFacade.ts index a0772ea55..93ed37f62 100644 --- a/packages/alphatab/src/platform/IUiFacade.ts +++ b/packages/alphatab/src/platform/IUiFacade.ts @@ -127,6 +127,19 @@ export interface IUiFacade { */ beginInvoke(action: () => void): void; + /** + * Creates a throttled/debounced version of the provided action. + * @param action The action to call. + * @param delay The delay to wait for additional call before actually executing. + * @returns A function which executes the provided action after the given delay. + * If multiple calls are made before the action is started, the already scheduled + * action is cancelled and a new one is scheduled after the given delay. + * If called endlessly, the action is never executed. + * + * Already executing actions will not be cancelled but will complete before another action executes. + */ + throttle(action: () => void, delay: number): () => void; + /** * Tells the UI layer to remove all highlights from highlighted music notation elements. */ @@ -176,7 +189,7 @@ export interface IUiFacade { scrollToX(scrollElement: IContainer, offset: number, speed: number): void; /** - * Stops any ongoing scrolling of the given element. + * Stops any ongoing scrolling of the given element. * @param scrollElement The element which might be scrolling dynamically. */ stopScrolling(scrollElement: IContainer): void; @@ -208,7 +221,7 @@ export interface IUiFacade { * Without these overflows we might not have enough scroll space * and we cannot reach a "sticky cursor" behavior. */ - setCanvasOverflow(canvasElement:IContainer, overflow: number, isVertical: boolean): void; + setCanvasOverflow(canvasElement: IContainer, overflow: number, isVertical: boolean): void; /** * This events is fired when the {@link canRender} property changes. diff --git a/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts b/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts index bc5de0060..6e9628def 100644 --- a/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts +++ b/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts @@ -1,17 +1,27 @@ -import { CircularSampleBuffer } from '@coderline/alphatab/synth/ds/CircularSampleBuffer'; import { Environment } from '@coderline/alphatab/Environment'; import { Logger } from '@coderline/alphatab/Logger'; -import { AlphaSynthWorkerSynthOutput } from '@coderline/alphatab/platform/worker/AlphaSynthWorkerSynthOutput'; +import type { Settings } from '@coderline/alphatab/Settings'; import { AlphaSynthWebAudioOutputBase } from '@coderline/alphatab/platform/javascript/AlphaSynthWebAudioOutputBase'; +import { BrowserUiFacade } from '@coderline/alphatab/platform/javascript/BrowserUiFacade'; +import type { + IAlphaSynthWorkerMessage, + IAlphaTabWorker +} from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; -import type { Settings } from '@coderline/alphatab/Settings'; +import { CircularSampleBuffer } from '@coderline/alphatab/synth/ds/CircularSampleBuffer'; + +/** + * @target web + * @internal + */ +type AudioWorkletProcessorMessagePort = Omit, 'terminate'>; /** * @target web * @internal */ interface AudioWorkletProcessor { - readonly port: MessagePort; + readonly port: AudioWorkletProcessorMessagePort; process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record): boolean; } @@ -24,6 +34,14 @@ declare let AudioWorkletProcessor: { new (options?: AudioWorkletNodeOptions): AudioWorkletProcessor; }; +/** + * @target web + * @internal + */ +interface AudioWorkletNode extends AudioNode { + readonly port: AudioWorkletProcessorMessagePort; +} + // Bug 646: Safari 14.1 is buggy regarding audio worklets // globalThis cannot be used to access registerProcessor or samplerate // we need to really use them as globals @@ -76,22 +94,22 @@ export class AlphaSynthWebWorklet { AlphaSynthWebWorkletProcessor.BufferSize * this._bufferCount ); - this.port.onmessage = this._handleMessage.bind(this); + this.port.addEventListener('message', e => this._handleMessage(e)); } - private _handleMessage(e: MessageEvent) { - const data: any = e.data; - const cmd: any = data.cmd; + private _handleMessage(e: MessageEvent) { + const data = e.data; + const cmd = data.cmd; switch (cmd) { - case AlphaSynthWorkerSynthOutput.CmdOutputAddSamples: + case 'alphaSynth.output.addSamples': const f: Float32Array = data.samples; this._circularBuffer.write(f, 0, f.length); this._requestedBufferCount--; break; - case AlphaSynthWorkerSynthOutput.CmdOutputResetSamples: + case 'alphaSynth.output.resetSamples': this._circularBuffer.clear(); break; - case AlphaSynthWorkerSynthOutput.CmdOutputStop: + case 'alphaSynth.output.stop': this._isStopped = true; break; } @@ -139,7 +157,7 @@ export class AlphaSynthWebWorklet { } this.port.postMessage({ - cmd: AlphaSynthWorkerSynthOutput.CmdOutputSamplesPlayed, + cmd: 'alphaSynth.output.samplesPlayed', samples: samplesFromBuffer / SynthConstants.AudioChannels }); this._requestBuffers(); @@ -161,7 +179,7 @@ export class AlphaSynthWebWorklet { if (bufferedSamples < halfSamples) { for (let i: number = 0; i < halfBufferCount; i++) { this.port.postMessage({ - cmd: AlphaSynthWorkerSynthOutput.CmdOutputSampleRequest + cmd: 'alphaSynth.output.sampleRequest' }); } this._requestedBufferCount += halfBufferCount; @@ -179,13 +197,15 @@ export class AlphaSynthWebWorklet { * @internal */ export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase { - private _worklet: AudioWorkletNode | null = null; + private _worklet: AudioWorkletNode | null = null; private _bufferTimeInMilliseconds: number = 0; private readonly _settings: Settings; + private _boundHandleMessage: (e: MessageEvent) => void; public constructor(settings: Settings) { super(); this._settings = settings; + this._boundHandleMessage = e => this._handleMessage(e); } public override open(bufferTimeInMilliseconds: number) { @@ -198,7 +218,7 @@ export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase { super.play(); const ctx = this.context!; // create a script processor node which will replace the silence with the generated audio - Environment.createAlphaSynthAudioWorklet(ctx, this._settings).then( + BrowserUiFacade.createAlphaSynthAudioWorklet(ctx, this._settings).then( () => { this._worklet = new AudioWorkletNode(ctx!, 'alphatab', { numberOfOutputs: 1, @@ -206,26 +226,27 @@ export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase { processorOptions: { bufferTimeInMilliseconds: this._bufferTimeInMilliseconds } - }); - this._worklet.port.onmessage = this._handleMessage.bind(this); + }) as AudioWorkletNode; + + this._worklet.port.addEventListener('message', this._boundHandleMessage); this.source!.connect(this._worklet); this.source!.start(0); this._worklet.connect(ctx!.destination); }, - (reason:any) => { + (reason: any) => { Logger.error('WebAudio', `Audio Worklet creation failed: reason=${reason}`); } ); } - private _handleMessage(e: MessageEvent) { - const data: any = e.data; - const cmd: any = data.cmd; + private _handleMessage(e: MessageEvent) { + const data = e.data; + const cmd = data.cmd; switch (cmd) { - case AlphaSynthWorkerSynthOutput.CmdOutputSamplesPlayed: + case 'alphaSynth.output.samplesPlayed': this.onSamplesPlayed(data.samples); break; - case AlphaSynthWorkerSynthOutput.CmdOutputSampleRequest: + case 'alphaSynth.output.sampleRequest': this.onSampleRequest(); break; } @@ -235,9 +256,9 @@ export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase { super.pause(); if (this._worklet) { this._worklet.port.postMessage({ - cmd: AlphaSynthWorkerSynthOutput.CmdOutputStop + cmd: 'alphaSynth.output.stop' }); - this._worklet.port.onmessage = null; + this._worklet.port.removeEventListener('message', this._boundHandleMessage); this._worklet.disconnect(); } this._worklet = null; @@ -245,14 +266,14 @@ export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase { public addSamples(f: Float32Array): void { this._worklet?.port.postMessage({ - cmd: AlphaSynthWorkerSynthOutput.CmdOutputAddSamples, + cmd: 'alphaSynth.output.addSamples', samples: Environment.prepareForPostMessage(f) }); } public resetSamples(): void { this._worklet?.port.postMessage({ - cmd: AlphaSynthWorkerSynthOutput.CmdOutputResetSamples + cmd: 'alphaSynth.output.resetSamples' }); } } diff --git a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts index edd9d4d17..cb4853dba 100644 --- a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts +++ b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts @@ -36,6 +36,8 @@ import { BackingTrackPlayer } from '@coderline/alphatab/synth/BackingTrackPlayer import { CoreSettings, FontFileFormat } from '@coderline/alphatab/CoreSettings'; import type { IAudioExporterWorker } from '@coderline/alphatab/synth/IAudioExporter'; import { AlphaSynthAudioExporterWorkerApi } from '@coderline/alphatab/platform/worker/AlphaSynthAudioExporterWorkerApi'; +import { AlphaTabWorker, AlphaSynthWorker } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; +import { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; /** * @target web @@ -186,7 +188,18 @@ export class BrowserUiFacade implements IUiFacade { } public createWorkerRenderer(): IScoreRenderer { - return new AlphaTabWorkerScoreRenderer(this._api, this._api.settings); + let worker: AlphaTabWorker | undefined; + try { + worker = BrowserUiFacade.createAlphaTabWebWorker(this._api.settings); + return new AlphaTabWorkerScoreRenderer(this._api, this._api.settings, worker); + } catch (e) { + Logger.error( + 'Renderer', + 'Failed to create worker for background rendering, fallback to non-worker rendering', + e + ); + return new ScoreRenderer(this._api.settings); + } } public initialize(api: AlphaTabApiBase, raw: SettingsJson | Settings): void { @@ -714,16 +727,33 @@ export class BrowserUiFacade implements IUiFacade { if (supportsAudioWorklets && this._api.settings.player.outputMode === PlayerOutputMode.WebAudioAudioWorklets) { Logger.debug('Player', 'Will use webworkers for synthesizing and web audio api with worklets for playback'); + let worker: AlphaSynthWorker | undefined; + try { + worker = BrowserUiFacade.createAlphaSynthWebWorker(this._api.settings); + } catch (e) { + Logger.error('Player', 'Failed to create worker for synthesizing audio', e); + return null; + } + player = new AlphaSynthWebWorkerApi( new AlphaSynthAudioWorkletOutput(this._api.settings), - this._api.settings + this._api.settings, + worker ); } else if (supportsScriptProcessor) { Logger.debug( 'Player', 'Will use webworkers for synthesizing and web audio api with ScriptProcessor for playback' ); - player = new AlphaSynthWebWorkerApi(new AlphaSynthScriptProcessorOutput(), this._api.settings); + let worker: AlphaSynthWorker | undefined; + try { + worker = BrowserUiFacade.createAlphaSynthWebWorker(this._api.settings); + } catch (e) { + Logger.error('Player', 'Failed to create worker for synthesizing audio', e); + return null; + } + + player = new AlphaSynthWebWorkerApi(new AlphaSynthScriptProcessorOutput(), this._api.settings, worker); } if (!player) { @@ -1018,4 +1048,28 @@ export class BrowserUiFacade implements IUiFacade { this._api.settings.player.bufferTimeInMilliseconds ); } + + public throttle(action: () => void, delay: number): () => void { + let timeoutId: number = 0; + return () => { + Environment.globalThis.clearTimeout(timeoutId); + timeoutId = Environment.globalThis.setTimeout(action, delay); + }; + } + + /** + * @internal + */ + public static createAlphaTabWebWorker: (settings: Settings) => AlphaTabWorker; + + /** + * @internal + */ + public static createAlphaSynthWebWorker: (settings: Settings) => AlphaSynthWorker; + + /** + * @target web + * @internal + */ + public static createAlphaSynthAudioWorklet: (context: AudioContext, settings: Settings) => Promise; } diff --git a/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts b/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts index 3da49bfe5..98c140501 100644 --- a/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts @@ -53,25 +53,32 @@ export class AlphaSynthAudioExporterWorkerApi implements IAudioExporterWorker { await this._promise.promise; } - public handleWorkerMessage(e: MessageEvent): void { - const data: any = e.data; + public handleWorkerMessage(e: MessageEvent): void { + const data = e.data; - // for us? - if (data.exporterId !== this._exporterId) { - return; - } - - const cmd: string = data.cmd; - switch (cmd) { + switch (data.cmd) { case 'alphaSynth.exporter.initialized': + // for us? + if (data.exporterId !== this._exporterId) { + return; + } + this._promise?.resolve(null); this._promise = null; break; case 'alphaSynth.exporter.error': + // for us? + if (data.exporterId !== this._exporterId) { + return; + } this._promise?.reject(data.error); this._promise = null; break; case 'alphaSynth.exporter.rendered': + // for us? + if (data.exporterId !== this._exporterId) { + return; + } this._promise?.resolve(data.chunk); this._promise = null; break; diff --git a/packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts b/packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts index 1b19e33cb..aecb1beb2 100644 --- a/packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts @@ -28,12 +28,8 @@ export class AlphaSynthWebWorker { main.addEventListener('message', e => this.handleMessage(e)); } - /** - * @target web - * @partial - */ public static init(): void { - new AlphaSynthWebWorker(Environment.globalThis); + new AlphaSynthWebWorker(Environment.getGlobalWorkerScope()); } public handleMessage(e: MessageEvent): void { @@ -42,7 +38,10 @@ export class AlphaSynthWebWorker { case 'alphaSynth.initialize': AlphaSynthWorkerSynthOutput.preferredSampleRate = data.sampleRate; Logger.logLevel = data.logLevel; - this._player = new AlphaSynth(new AlphaSynthWorkerSynthOutput(), data.bufferTimeInMilliseconds); + this._player = new AlphaSynth( + new AlphaSynthWorkerSynthOutput(this._main), + data.bufferTimeInMilliseconds + ); this._player.positionChanged.on(e => this.onPositionChanged(e)); this._player.stateChanged.on(e => this.onPlayerStateChanged(e)); this._player.finished.on(() => this.onFinished()); @@ -142,15 +141,16 @@ export class AlphaSynthWebWorker { this._handleExporterMessage(e); } } - private _handleExporterMessage(e: MessageEvent) { - const data = e.data; + private _handleExporterMessage(ev: MessageEvent) { + const data = ev.data; const cmd = data.cmd; + let exporter:IAlphaSynthAudioExporter|undefined = undefined; let exporterId = 0; try { switch (cmd) { case 'alphaSynth.exporter.initialize': exporterId = data.exporterId; - const exporter = this._player.exportAudio( + exporter = this._player.exportAudio( data.options, JsonConverter.jsObjectToMidiFile(data.midi), data.syncPoints, @@ -167,7 +167,7 @@ export class AlphaSynthWebWorker { case 'alphaSynth.exporter.render': exporterId = data.exporterId; if (this._exporter.has(data.exporterId)) { - const exporter = this._exporter.get(data.exporterId)!; + exporter = this._exporter.get(data.exporterId)!; const chunk = exporter.render(data.milliseconds); this._main.postMessage({ cmd: 'alphaSynth.exporter.rendered', diff --git a/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts b/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts index 8169a54aa..614c209f7 100644 --- a/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts @@ -229,7 +229,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { }); } - public constructor(player: ISynthOutput, settings: Settings) { + public constructor(player: ISynthOutput, settings: Settings, synthWorker: AlphaSynthWorker) { this._workerIsReadyForPlayback = false; this._workerIsReady = false; this._outputIsReady = false; @@ -244,11 +244,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { this._output.samplesPlayed.on(this.onOutputSamplesPlayed.bind(this)); this._output.sampleRequest.on(this.onOutputSampleRequest.bind(this)); this._output.open(settings.player.bufferTimeInMilliseconds); - try { - this._synth = Environment.createAlphaSynthWebWorker(settings); - } catch (e) { - Logger.error('AlphaSynth', `Failed to create WebWorker: ${e}`); - } + this._synth = synthWorker; this._synth.addEventListener('message', e => this.handleWorkerMessage(e)); this._synth.postMessage({ cmd: 'alphaSynth.initialize', @@ -390,7 +386,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { break; case 'alphaSynth.midiEventsPlayed': (this.midiEventsPlayed as EventEmitterOfT).trigger( - new MidiEventsPlayedEventArgs((data.events as unknown[]).map(JsonConverter.jsObjectToMidiEvent)) + new MidiEventsPlayedEventArgs(data.events.map(JsonConverter.jsObjectToMidiEvent)) ); break; case 'alphaSynth.playerStateChanged': diff --git a/packages/alphatab/src/platform/worker/AlphaSynthWorkerSynthOutput.ts b/packages/alphatab/src/platform/worker/AlphaSynthWorkerSynthOutput.ts index 6d13b4c4d..d773bdacb 100644 --- a/packages/alphatab/src/platform/worker/AlphaSynthWorkerSynthOutput.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWorkerSynthOutput.ts @@ -16,30 +16,22 @@ import type { ISynthOutput, ISynthOutputDevice } from '@coderline/alphatab/synth * @internal */ export class AlphaSynthWorkerSynthOutput implements ISynthOutput { - public static readonly CmdOutputPrefix: string = 'alphaSynth.output.'; - public static readonly CmdOutputAddSamples: string = `${AlphaSynthWorkerSynthOutput.CmdOutputPrefix}addSamples`; - public static readonly CmdOutputPlay: string = `${AlphaSynthWorkerSynthOutput.CmdOutputPrefix}play`; - public static readonly CmdOutputPause: string = `${AlphaSynthWorkerSynthOutput.CmdOutputPrefix}pause`; - public static readonly CmdOutputResetSamples: string = `${AlphaSynthWorkerSynthOutput.CmdOutputPrefix}resetSamples`; - public static readonly CmdOutputStop: string = `${AlphaSynthWorkerSynthOutput.CmdOutputPrefix}stop`; - public static readonly CmdOutputSampleRequest: string = - `${AlphaSynthWorkerSynthOutput.CmdOutputPrefix}sampleRequest`; - public static readonly CmdOutputSamplesPlayed: string = - `${AlphaSynthWorkerSynthOutput.CmdOutputPrefix}samplesPlayed`; - // this value is initialized by the alphaSynth WebWorker wrapper // that also includes the alphaSynth library into the worker. public static preferredSampleRate: number = 0; - private _main!: IAlphaTabWorkerGlobalScope; + private _main: IAlphaTabWorkerGlobalScope; public get sampleRate(): number { return AlphaSynthWorkerSynthOutput.preferredSampleRate; } - public open(): void { + public constructor(main: IAlphaTabWorkerGlobalScope) { + this._main = main; + } + + public open(_sampleRate: number): void { Logger.debug('AlphaSynth', 'Initializing synth worker'); - this._main = Environment.globalThis; this._main.addEventListener('message', this._handleMessage.bind(this)); (this.ready as EventEmitter).trigger(); } @@ -50,14 +42,13 @@ export class AlphaSynthWorkerSynthOutput implements ISynthOutput { }); } - private _handleMessage(e: MessageEvent): void { - const data: any = e.data; - const cmd: any = data.cmd; - switch (cmd) { - case AlphaSynthWorkerSynthOutput.CmdOutputSampleRequest: + private _handleMessage(e: MessageEvent): void { + const data = e.data; + switch (data.cmd) { + case 'alphaSynth.output.sampleRequest': (this.sampleRequest as EventEmitter).trigger(); break; - case AlphaSynthWorkerSynthOutput.CmdOutputSamplesPlayed: + case 'alphaSynth.output.samplesPlayed': (this.samplesPlayed as EventEmitterOfT).trigger(data.samples); break; } diff --git a/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts b/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts index 2532d6950..9c0220006 100644 --- a/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts @@ -13,7 +13,7 @@ import { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; import type { Settings } from '@coderline/alphatab/Settings'; /** - * @public + * @internal * @partial */ export class AlphaTabWebWorker { @@ -25,16 +25,12 @@ export class AlphaTabWebWorker { main.addEventListener('message', e => this._handleMessage(e)); } - /** - * @target web - * @partial - */ public static init(): void { - new AlphaTabWebWorker(Environment.globalThis); + new AlphaTabWebWorker(Environment.getGlobalWorkerScope()); } - private _handleMessage(e: MessageEvent): void { - const data = e.data as IAlphaTabWorkerMessage; + private _handleMessage(e: MessageEvent): void { + const data = e.data; if (!data?.cmd) { return; } diff --git a/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts b/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts index b405e1149..b4bddf42b 100644 --- a/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts @@ -36,6 +36,9 @@ export type IAlphaTabWorkerMessage = | { cmd: 'alphaTab.postRenderFinished'; boundsLookup: Map | null } | { cmd: 'alphaTab.error'; error: Error }; +/** + * @internal + */ export interface IAlphaTabWorker { postMessage(message: T): void; addEventListener(event: 'message', handler: (ev: MessageEvent) => void): void; @@ -43,7 +46,10 @@ export interface IAlphaTabWorker { terminate(): void; } -export interface IAlphaTabWorkerGlobalScope { +/** + * @internal + */ +export interface IAlphaTabWorkerGlobalScope { postMessage(message: T): void; addEventListener(event: 'message', handler: (ev: MessageEvent) => void): void; removeEventListener(event: 'message', handler: (ev: MessageEvent) => void): void; @@ -121,6 +127,7 @@ export type IAlphaSynthWorkerMessage = | { cmd: 'alphaSynth.output.addSamples'; samples: Float32Array } | { cmd: 'alphaSynth.output.play' } | { cmd: 'alphaSynth.output.pause' } + | { cmd: 'alphaSynth.output.stop' } | { cmd: 'alphaSynth.output.destroy' } | { cmd: 'alphaSynth.output.resetSamples' }; diff --git a/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts b/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts index fe782e42e..23e9978bb 100644 --- a/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts @@ -1,23 +1,22 @@ import type { AlphaTabApiBase } from '@coderline/alphatab/AlphaTabApiBase'; +import { Environment } from '@coderline/alphatab/Environment'; import { EventEmitter, - type IEventEmitterOfT, + EventEmitterOfT, type IEventEmitter, - EventEmitterOfT + type IEventEmitterOfT } from '@coderline/alphatab/EventEmitter'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import type { Score } from '@coderline/alphatab/model/Score'; import { FontSizes } from '@coderline/alphatab/platform/svg/FontSizes'; -import type { IScoreRenderer, RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; -import type { 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'; -import { Environment } from '@coderline/alphatab/Environment'; import type { AlphaTabWorker, IAlphaTabWorkerMessage } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; +import type { IScoreRenderer, RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; +import type { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; +import { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup'; +import type { Settings } from '@coderline/alphatab/Settings'; /** * @public @@ -29,15 +28,9 @@ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { public boundsLookup: BoundsLookup | null = null; - public constructor(api: AlphaTabApiBase, settings: Settings) { + public constructor(api: AlphaTabApiBase, settings: Settings, worker: AlphaTabWorker) { this._api = api; - - try { - this._worker = Environment.createAlphaTabWebWorker(settings); - } catch (e) { - Logger.error('Rendering', `Failed to create WebWorker: ${e}`); - return; - } + this._worker = worker; this._worker.postMessage({ cmd: 'alphaTab.initialize', settings: this._serializeSettingsForWorker(settings) diff --git a/packages/alphatab/src/rendering/utils/BoundsLookup.ts b/packages/alphatab/src/rendering/utils/BoundsLookup.ts index 97c324f21..396a7837f 100644 --- a/packages/alphatab/src/rendering/utils/BoundsLookup.ts +++ b/packages/alphatab/src/rendering/utils/BoundsLookup.ts @@ -103,17 +103,17 @@ export class BoundsLookup { ); bb.realBounds = BoundsLookup._boundsFromJson(beat.get('realBounds') as Map); bb.onNotesX = beat.get('onNotesX') as number; - const bd: any = beat; bb.beat = - score.tracks[bd.trackIndex].staves[bd.staffIndex].bars[bd.barIndex].voices[ - bd.voiceIndex - ].beats[bd.beatIndex]; + score.tracks[beat.get('trackIndex') as number].staves[ + beat.get('staffIndex') as number + ].bars[beat.get('barIndex') as number].voices[beat.get('voiceIndex') as number].beats[ + beat.get('beatIndex') as number + ]; if (beat.has('notes')) { bb.notes = []; for (const note of beat.get('notes') as Map[]) { const n: NoteBounds = new NoteBounds(); - const nd: any = note; - n.note = bb.beat.notes[nd.index]; + n.note = bb.beat.notes[note.get('index') as number]; n.noteHeadBounds = BoundsLookup._boundsFromJson( note.get('noteHeadBounds') as Map ); diff --git a/packages/alphatab/test/visualTests/TestUiFacade.ts b/packages/alphatab/test/visualTests/TestUiFacade.ts index 6e9efc966..539a9174b 100644 --- a/packages/alphatab/test/visualTests/TestUiFacade.ts +++ b/packages/alphatab/test/visualTests/TestUiFacade.ts @@ -106,6 +106,13 @@ class TestUiContainer implements IContainer { this.childNodes = []; } + public throttle(action: () => void, _delay: number): () => void { + // no throttling. + return () => { + action(); + }; + } + public resize: IEventEmitter = new EventEmitter(); public mouseDown: IEventEmitterOfT = new EventEmitterOfT(); @@ -331,6 +338,12 @@ export class TestUiFacade implements IUiFacade { return null; } + public throttle(action: () => void, _delay: number): () => void { + return () => { + action(); + }; + } + public readonly canRenderChanged: IEventEmitter = new EventEmitter(); public readonly rootContainerBecameVisible: IEventEmitter = new EventEmitter(); } From 9e9e9f78c1a0dc1c648cfb27b22bde07c08b5ef0 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Wed, 8 Apr 2026 18:36:26 +0200 Subject: [PATCH 03/11] refactor(csharp): use worker style components --- .../platform/javascript/BrowserUiFacade.ts | 14 +- .../platform/worker/AlphaSynthWebWorkerApi.ts | 8 +- .../platform/worker/AlphaTabWorkerProtocol.ts | 4 +- .../worker/AlphaTabWorkerScoreRenderer.ts | 10 +- .../WinForms/WinFormsUiFacade.cs | 2 +- .../src/AlphaTab.Windows/Wpf/WpfUiFacade.cs | 4 +- .../AlphaTab/Core/EcmaScript/MessageEvent.cs | 11 + .../src/AlphaTab/Core/EcmaScript/Promise.cs | 11 + .../Core/EcmaScript/PromiseWithResolvers.cs | 19 ++ packages/csharp/src/AlphaTab/Environment.cs | 44 ++- .../CSharp/AlphaSynthWorkerApiBase.cs | 273 ----------------- .../ManagedThreadAlphaSynthAudioExporter.cs | 103 ------- .../CSharp/ManagedThreadAlphaSynthWorker.cs | 159 ++++++++++ .../ManagedThreadAlphaSynthWorkerApi.cs | 78 ----- .../CSharp/ManagedThreadScoreRenderer.cs | 215 ------------- .../Platform/CSharp/ManagedUiFacade.cs | 107 +++++-- packages/transpiler/src/csharp/CSharpAst.ts | 3 +- .../transpiler/src/csharp/CSharpAstPrinter.ts | 17 +- .../src/csharp/CSharpAstTransformer.ts | 286 +++++++++++++++++- .../src/csharp/CSharpEmitterContext.ts | 32 +- 20 files changed, 644 insertions(+), 756 deletions(-) create mode 100644 packages/csharp/src/AlphaTab/Core/EcmaScript/MessageEvent.cs create mode 100644 packages/csharp/src/AlphaTab/Core/EcmaScript/PromiseWithResolvers.cs delete mode 100644 packages/csharp/src/AlphaTab/Platform/CSharp/AlphaSynthWorkerApiBase.cs delete mode 100644 packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthAudioExporter.cs create mode 100644 packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs delete mode 100644 packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorkerApi.cs delete mode 100644 packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadScoreRenderer.cs diff --git a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts index cb4853dba..c3205d2b4 100644 --- a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts +++ b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts @@ -36,7 +36,7 @@ import { BackingTrackPlayer } from '@coderline/alphatab/synth/BackingTrackPlayer import { CoreSettings, FontFileFormat } from '@coderline/alphatab/CoreSettings'; import type { IAudioExporterWorker } from '@coderline/alphatab/synth/IAudioExporter'; import { AlphaSynthAudioExporterWorkerApi } from '@coderline/alphatab/platform/worker/AlphaSynthAudioExporterWorkerApi'; -import { AlphaTabWorker, AlphaSynthWorker } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; +import { IAlphaTabRenderingWorker, IAlphaSynthWorker } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; import { ScoreRenderer } from '@coderline/alphatab/rendering/ScoreRenderer'; /** @@ -188,10 +188,10 @@ export class BrowserUiFacade implements IUiFacade { } public createWorkerRenderer(): IScoreRenderer { - let worker: AlphaTabWorker | undefined; + let worker: IAlphaTabRenderingWorker | undefined; try { worker = BrowserUiFacade.createAlphaTabWebWorker(this._api.settings); - return new AlphaTabWorkerScoreRenderer(this._api, this._api.settings, worker); + return new AlphaTabWorkerScoreRenderer(this._api, worker); } catch (e) { Logger.error( 'Renderer', @@ -727,7 +727,7 @@ export class BrowserUiFacade implements IUiFacade { if (supportsAudioWorklets && this._api.settings.player.outputMode === PlayerOutputMode.WebAudioAudioWorklets) { Logger.debug('Player', 'Will use webworkers for synthesizing and web audio api with worklets for playback'); - let worker: AlphaSynthWorker | undefined; + let worker: IAlphaSynthWorker | undefined; try { worker = BrowserUiFacade.createAlphaSynthWebWorker(this._api.settings); } catch (e) { @@ -745,7 +745,7 @@ export class BrowserUiFacade implements IUiFacade { 'Player', 'Will use webworkers for synthesizing and web audio api with ScriptProcessor for playback' ); - let worker: AlphaSynthWorker | undefined; + let worker: IAlphaSynthWorker | undefined; try { worker = BrowserUiFacade.createAlphaSynthWebWorker(this._api.settings); } catch (e) { @@ -1060,12 +1060,12 @@ export class BrowserUiFacade implements IUiFacade { /** * @internal */ - public static createAlphaTabWebWorker: (settings: Settings) => AlphaTabWorker; + public static createAlphaTabWebWorker: (settings: Settings) => IAlphaTabRenderingWorker; /** * @internal */ - public static createAlphaSynthWebWorker: (settings: Settings) => AlphaSynthWorker; + public static createAlphaSynthWebWorker: (settings: Settings) => IAlphaSynthWorker; /** * @target web diff --git a/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts b/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts index 614c209f7..8d4f8bc0d 100644 --- a/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts @@ -13,7 +13,7 @@ import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import type { Score } from '@coderline/alphatab/model/Score'; import type { - AlphaSynthWorker, + IAlphaSynthWorker, IAlphaSynthWorkerMessage } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; import type { Settings } from '@coderline/alphatab/Settings'; @@ -32,7 +32,7 @@ import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; * @internal */ export class AlphaSynthWebWorkerApi implements IAlphaSynth { - private _synth!: AlphaSynthWorker; + private _synth!: IAlphaSynthWorker; private _output: ISynthOutput; private _workerIsReadyForPlayback: boolean = false; private _workerIsReady: boolean = false; @@ -68,7 +68,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { return Logger.logLevel; } - public get worker(): AlphaSynthWorker { + public get worker(): IAlphaSynthWorker { return this._synth; } @@ -229,7 +229,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { }); } - public constructor(player: ISynthOutput, settings: Settings, synthWorker: AlphaSynthWorker) { + public constructor(player: ISynthOutput, settings: Settings, synthWorker: IAlphaSynthWorker) { this._workerIsReadyForPlayback = false; this._workerIsReady = false; this._outputIsReady = false; diff --git a/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts b/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts index b4bddf42b..c29afe0fe 100644 --- a/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts @@ -134,9 +134,9 @@ export type IAlphaSynthWorkerMessage = /** * @internal */ -export interface AlphaTabWorker extends IAlphaTabWorker {} +export interface IAlphaTabRenderingWorker extends IAlphaTabWorker {} /** * @internal */ -export interface AlphaSynthWorker extends IAlphaTabWorker {} +export interface IAlphaSynthWorker extends IAlphaTabWorker {} diff --git a/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts b/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts index 23e9978bb..92e5b1bd4 100644 --- a/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts @@ -10,7 +10,7 @@ import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import type { Score } from '@coderline/alphatab/model/Score'; import { FontSizes } from '@coderline/alphatab/platform/svg/FontSizes'; import type { - AlphaTabWorker, + IAlphaTabRenderingWorker, IAlphaTabWorkerMessage } from '@coderline/alphatab/platform/worker/AlphaTabWorkerProtocol'; import type { IScoreRenderer, RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; @@ -19,21 +19,21 @@ import { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup'; import type { Settings } from '@coderline/alphatab/Settings'; /** - * @public + * @internal */ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { private _api: AlphaTabApiBase; - private _worker!: AlphaTabWorker; + private _worker!: IAlphaTabRenderingWorker; private _width: number = 0; public boundsLookup: BoundsLookup | null = null; - public constructor(api: AlphaTabApiBase, settings: Settings, worker: AlphaTabWorker) { + public constructor(api: AlphaTabApiBase, worker: IAlphaTabRenderingWorker) { this._api = api; this._worker = worker; this._worker.postMessage({ cmd: 'alphaTab.initialize', - settings: this._serializeSettingsForWorker(settings) + settings: this._serializeSettingsForWorker(api.settings) }); this._worker.addEventListener('message', e => this._handleWorkerMessage(e)); } diff --git a/packages/csharp/src/AlphaTab.Windows/WinForms/WinFormsUiFacade.cs b/packages/csharp/src/AlphaTab.Windows/WinForms/WinFormsUiFacade.cs index bd4518ffd..dcfbef075 100644 --- a/packages/csharp/src/AlphaTab.Windows/WinForms/WinFormsUiFacade.cs +++ b/packages/csharp/src/AlphaTab.Windows/WinForms/WinFormsUiFacade.cs @@ -245,7 +245,7 @@ public override void DestroyCursors() return null; } - public override void BeginInvoke(Action action) + protected override void PostToUIThread(Action action) { SettingsContainer.BeginInvoke(action); } diff --git a/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs b/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs index 042051e27..648d4a861 100644 --- a/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs +++ b/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs @@ -109,7 +109,7 @@ protected override ISynthOutput CreateSynthOutput() return new NAudioSynthOutput(); } - public override IAlphaSynth? CreateBackingTrackPlayer() + public override IAlphaSynth CreateBackingTrackPlayer() { return new BackingTrackPlayer( new NAudioBackingTrackOutput(BeginInvoke), @@ -294,7 +294,7 @@ public override Cursors CreateCursors() ); } - public override void BeginInvoke(Action action) + protected override void PostToUIThread(Action action) { SettingsContainer.Dispatcher?.BeginInvoke(action); } diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/MessageEvent.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/MessageEvent.cs new file mode 100644 index 000000000..6fcdbf3fa --- /dev/null +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/MessageEvent.cs @@ -0,0 +1,11 @@ +namespace AlphaTab.Core.EcmaScript; + +internal class MessageEvent +{ + public MessageEvent(T data) + { + Data = data; + } + + public T Data { get; } +} diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/Promise.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Promise.cs index 7cffa6a2d..5965bc965 100644 --- a/packages/csharp/src/AlphaTab/Core/EcmaScript/Promise.cs +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/Promise.cs @@ -12,4 +12,15 @@ public static async Task Race(IList tasks) var completed = await Task.WhenAny(tasks); await completed; } + + public static PromiseWithResolvers WithResolvers() + { + return new PromiseWithResolvers(); + } + + + public static PromiseWithResolvers WithResolvers() + { + return new PromiseWithResolvers(); + } } diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/PromiseWithResolvers.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/PromiseWithResolvers.cs new file mode 100644 index 000000000..02f1d65ba --- /dev/null +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/PromiseWithResolvers.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace AlphaTab.Core.EcmaScript; + +public class PromiseWithResolvers +{ + private readonly TaskCompletionSource _taskCompletionSource = new(); + public Task Promise => _taskCompletionSource.Task; + + public void Resolve(T o) + { + _taskCompletionSource.TrySetResult(o); + } + + public void Reject(Error error) + { + _taskCompletionSource.TrySetException(error); + } +} diff --git a/packages/csharp/src/AlphaTab/Environment.cs b/packages/csharp/src/AlphaTab/Environment.cs index 0948893bc..46e9bfb37 100644 --- a/packages/csharp/src/AlphaTab/Environment.cs +++ b/packages/csharp/src/AlphaTab/Environment.cs @@ -1,21 +1,19 @@ using System; -using System.Collections; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; -using System.Threading; -using System.Threading.Tasks; using AlphaTab.Collections; using AlphaTab.Platform; using AlphaTab.Platform.CSharp; +using AlphaTab.Platform.Worker; namespace AlphaTab; partial class Environment { - public const bool SupportsTextDecoder = true; - - public static void PlatformInit() + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T PrepareForPostMessage(T o) { + return o; } private static void _printPlatformInfo(System.Action print) @@ -26,23 +24,6 @@ private static void _printPlatformInfo(System.Action print) print($"OS Arch: {RuntimeInformation.OSArchitecture}"); } - public static Action Throttle(Action action, double delay) - { - CancellationTokenSource? cancellationTokenSource = null; - return () => - { - cancellationTokenSource?.Cancel(); - cancellationTokenSource = new CancellationTokenSource(); - - Task.Run(async () => - { - await Task.Delay((int)delay, cancellationTokenSource.Token); - action(); - }, - cancellationTokenSource.Token); - }; - } - private static void _createPlatformSpecificRenderEngines( IMap renderEngines) { @@ -63,4 +44,21 @@ internal static void SortDescending(System.Collections.Generic.IList lis { list.Sort((a, b) => b - a); } + + + internal static IAlphaTabWorkerGlobalScope GetGlobalWorkerScope() + { + if (typeof(T) == typeof(IAlphaSynthWorkerMessage)) + { + return (IAlphaTabWorkerGlobalScope)ManagedThreadAlphaSynthWorker.CurrentThreadWorker; + } + + if (typeof(T) == typeof(IAlphaTabWorkerMessage)) + { + return (IAlphaTabWorkerGlobalScope)ManagedThreadAlphaTabRendererWorker + .CurrentThreadWorker; + } + + throw new InvalidOperationException("Unsupported worker scope kind"); + } } diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/AlphaSynthWorkerApiBase.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/AlphaSynthWorkerApiBase.cs deleted file mode 100644 index f79c64142..000000000 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/AlphaSynthWorkerApiBase.cs +++ /dev/null @@ -1,273 +0,0 @@ -using System; -using System.Collections.Generic; -using AlphaTab.Collections; -using AlphaTab.Midi; -using AlphaTab.Model; -using AlphaTab.Synth; - -namespace AlphaTab.Platform.CSharp; - -internal abstract class AlphaSynthWorkerApiBase : IAlphaSynth -{ - private LogLevel _logLevel; - private readonly double _bufferTimeInMilliseconds; - - public AlphaSynth? Player { get; private set; } - - protected AlphaSynthWorkerApiBase(ISynthOutput output, LogLevel logLevel, double bufferTimeInMilliseconds) - { - Output = output; - _logLevel = logLevel; - _bufferTimeInMilliseconds = bufferTimeInMilliseconds; - Player = null!; - } - - public ISynthOutput Output { get; } - - public abstract void Destroy(); - protected abstract void DispatchOnUiThread(Action action); - protected internal abstract void DispatchOnWorkerThread(Action action); - - protected void Initialize() - { - Player = new AlphaSynth(Output, _bufferTimeInMilliseconds); - Player.PositionChanged.On(OnPositionChanged); - Player.StateChanged.On(OnStateChanged); - Player.Finished.On(OnFinished); - Player.SoundFontLoaded.On(OnSoundFontLoaded); - Player.SoundFontLoadFailed.On(OnSoundFontLoadFailed); - Player.MidiLoaded.On(OnMidiLoaded); - Player.MidiLoadFailed.On(OnMidiLoadFailed); - Player.ReadyForPlayback.On(OnReadyForPlayback); - Player.MidiEventsPlayed.On(OnMidiEventsPlayed); - Player.PlaybackRangeChanged.On(OnPlaybackRangeChanged); - - DispatchOnUiThread(OnReady); - } - - public bool IsReady => Player?.IsReady ?? false; - public bool IsReadyForPlayback => Player?.IsReadyForPlayback ?? false; - - public PlayerState State => Player?.State ?? PlayerState.Paused; - - public LogLevel LogLevel - { - get => _logLevel; - set - { - _logLevel = value; - DispatchOnWorkerThread(() => { Player.LogLevel = value; }); - } - } - - public double MasterVolume - { - get => Player.MasterVolume; - set => DispatchOnWorkerThread(() => { Player.MasterVolume = value; }); - } - - public double CountInVolume - { - get => Player.CountInVolume; - set => DispatchOnWorkerThread(() => { Player.CountInVolume = value; }); - } - - public IList MidiEventsPlayedFilter - { - get => Player.MidiEventsPlayedFilter; - set => DispatchOnWorkerThread(() => { Player.MidiEventsPlayedFilter = value; }); - } - - public double MetronomeVolume - { - get => Player.MetronomeVolume; - set => DispatchOnWorkerThread(() => { Player.MetronomeVolume = value; }); - } - - public double PlaybackSpeed - { - get => Player.PlaybackSpeed; - set => DispatchOnWorkerThread(() => { Player.PlaybackSpeed = value; }); - } - - public double TickPosition - { - get => Player.TickPosition; - set => DispatchOnWorkerThread(() => { Player.TickPosition = value; }); - } - - public double TimePosition - { - get => Player.TimePosition; - set => DispatchOnWorkerThread(() => { Player.TimePosition = value; }); - } - - public PlaybackRange? PlaybackRange - { - get => Player.PlaybackRange; - set => DispatchOnWorkerThread(() => { Player.PlaybackRange = value; }); - } - - public bool IsLooping - { - get => Player.IsLooping; - set => DispatchOnWorkerThread(() => { Player.IsLooping = value; }); - } - - public PositionChangedEventArgs? LoadedMidiInfo => Player.LoadedMidiInfo; - public PositionChangedEventArgs CurrentPosition => Player.CurrentPosition; - - public bool Play() - { - if (State == PlayerState.Playing || !IsReadyForPlayback) - { - return false; - } - - DispatchOnWorkerThread(() => { Player.Play(); }); - return true; - } - - public void Pause() - { - DispatchOnWorkerThread(() => { Player.Pause(); }); - } - - public void PlayOneTimeMidiFile(MidiFile midiFile) - { - DispatchOnWorkerThread(() => { Player.PlayOneTimeMidiFile(midiFile); }); - } - - public void PlayPause() - { - DispatchOnWorkerThread(() => { Player.PlayPause(); }); - } - - public void Stop() - { - DispatchOnWorkerThread(() => { Player.Stop(); }); - } - - public void ResetSoundFonts() - { - DispatchOnWorkerThread(() => { Player.ResetSoundFonts(); }); - } - - public void LoadSoundFont(Uint8Array data, bool append) - { - DispatchOnWorkerThread(() => { Player.LoadSoundFont(data, append); }); - } - - public void LoadMidiFile(MidiFile midi) - { - DispatchOnWorkerThread(() => { Player.LoadMidiFile(midi); }); - } - - public void ApplyTranspositionPitches(IValueTypeMap transpositionPitches) - { - DispatchOnWorkerThread(() => { Player.ApplyTranspositionPitches(transpositionPitches); }); - } - - public void SetChannelMute(double channel, bool mute) - { - DispatchOnWorkerThread(() => { Player.SetChannelMute(channel, mute); }); - } - - public void ResetChannelStates() - { - DispatchOnWorkerThread(() => { Player.ResetChannelStates(); }); - } - - public void SetChannelSolo(double channel, bool solo) - { - DispatchOnWorkerThread(() => { Player.SetChannelSolo(channel, solo); }); - } - - public void SetChannelVolume(double channel, double volume) - { - DispatchOnWorkerThread(() => { Player.SetChannelVolume(channel, volume); }); - } - - public void SetChannelTranspositionPitch(double channel, double semitones) - { - DispatchOnWorkerThread(() => { Player.SetChannelTranspositionPitch(channel, semitones); }); - } - - public void LoadBackingTrack(Score score) - { - DispatchOnWorkerThread(() => { Player.LoadBackingTrack(score); }); - } - - public void UpdateSyncPoints( IList syncPoints) - { - DispatchOnWorkerThread(() => { Player.UpdateSyncPoints(syncPoints); }); - } - - public IEventEmitter Ready { get; } = new EventEmitter(); - public IEventEmitter ReadyForPlayback { get; } = new EventEmitter(); - public IEventEmitter Finished { get; } = new EventEmitter(); - public IEventEmitter SoundFontLoaded { get; } = new EventEmitter(); - public IEventEmitterOfT SoundFontLoadFailed { get; } = new EventEmitterOfT(); - public IEventEmitterOfT MidiLoad { get; } = new EventEmitterOfT(); - public IEventEmitterOfT MidiLoaded { get; } = new EventEmitterOfT(); - public IEventEmitterOfT MidiLoadFailed { get; } = new EventEmitterOfT(); - public IEventEmitterOfT StateChanged { get; } = new EventEmitterOfT(); - public IEventEmitterOfT PositionChanged { get; } = new EventEmitterOfT(); - public IEventEmitterOfT MidiEventsPlayed { get; } = new EventEmitterOfT(); - public IEventEmitterOfT PlaybackRangeChanged { get; } = new EventEmitterOfT(); - - protected virtual void OnReady() - { - DispatchOnUiThread(() => ((EventEmitter)Ready).Trigger()); - } - - protected virtual void OnReadyForPlayback() - { - DispatchOnUiThread(() => ((EventEmitter)ReadyForPlayback).Trigger()); - } - - protected virtual void OnFinished() - { - DispatchOnUiThread(() => ((EventEmitter)Finished).Trigger()); - } - - protected virtual void OnSoundFontLoaded() - { - DispatchOnUiThread(() => ((EventEmitter)SoundFontLoaded).Trigger()); - } - - protected virtual void OnSoundFontLoadFailed(Error e) - { - DispatchOnUiThread(() => ((EventEmitterOfT)SoundFontLoadFailed).Trigger(e)); - } - - protected virtual void OnMidiLoaded(PositionChangedEventArgs args) - { - DispatchOnUiThread(() => ((EventEmitterOfT)MidiLoaded).Trigger(args)); - } - - protected virtual void OnMidiLoadFailed(Error e) - { - DispatchOnUiThread(() => ((EventEmitterOfT)MidiLoadFailed).Trigger(e)); - } - - protected virtual void OnMidiEventsPlayed(MidiEventsPlayedEventArgs e) - { - DispatchOnUiThread(() => ((EventEmitterOfT)MidiEventsPlayed).Trigger(e)); - } - - protected virtual void OnStateChanged(PlayerStateChangedEventArgs obj) - { - DispatchOnUiThread(() => ((EventEmitterOfT)StateChanged).Trigger(obj)); - } - - protected virtual void OnPositionChanged(PositionChangedEventArgs obj) - { - DispatchOnUiThread(() => ((EventEmitterOfT)PositionChanged).Trigger(obj)); - } - - protected virtual void OnPlaybackRangeChanged(PlaybackRangeChangedEventArgs obj) - { - DispatchOnUiThread(() => ((EventEmitterOfT)PlaybackRangeChanged).Trigger(obj)); - } -} diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthAudioExporter.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthAudioExporter.cs deleted file mode 100644 index 984478225..000000000 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthAudioExporter.cs +++ /dev/null @@ -1,103 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading.Tasks; -using AlphaTab.Collections; -using AlphaTab.Synth; - -namespace AlphaTab.Platform.CSharp; - -internal class ManagedThreadAlphaSynthAudioExporter : IAudioExporterWorker -{ - private readonly ManagedThreadAlphaSynthWorkerApi _worker; - private readonly bool _ownsWorker; - private TaskCompletionSource? _promise; - private IAlphaSynthAudioExporter? _exporter; - - public ManagedThreadAlphaSynthAudioExporter(ManagedThreadAlphaSynthWorkerApi synthWorker, - bool ownsWorker) - { - _worker = synthWorker; - _ownsWorker = ownsWorker; - } - - private async Task DispatchAsyncOnWorkerThread(Action action) - { - if (_promise != null) - { - throw new AlphaTabError( - AlphaTabErrorType.General, - "There is already an ongoing operation, wait for previous operation to complete before proceeding" - ); - } - - _promise = new TaskCompletionSource(); - try - { - _worker.DispatchOnWorkerThread(() => - { - try - { - action(); - _promise.SetResult(1); - } - catch (Error e) - { - _promise.SetException(e); - } - }); - - await _promise.Task; - } - finally - { - _promise = null; - } - } - - public async Task Initialize( - AudioExportOptions options, - Midi.MidiFile midi, - IList syncPoints, - IValueTypeMap transpositionPitches) - { - await DispatchAsyncOnWorkerThread(() => - { - _exporter = _worker.Player.ExportAudio( - options, - midi, - syncPoints, - transpositionPitches - ); - }); - } - - public async Task Render(double milliseconds) - { - AudioExportChunk? chunk = null; - await DispatchAsyncOnWorkerThread(() => - { - if (_exporter == null) - { - throw new AlphaTabError(AlphaTabErrorType.General, - "Exporter was already destroyed"); - } - - chunk = _exporter.Render(milliseconds); - }); - return chunk; - } - - public void Destroy() - { - _exporter = null; - if (_ownsWorker) - { - _worker.Destroy(); - } - } - - public void Dispose() - { - Destroy(); - } -} diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs new file mode 100644 index 000000000..ba10fcf54 --- /dev/null +++ b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using AlphaTab.Collections; +using AlphaTab.Platform.Worker; + +namespace AlphaTab.Platform.CSharp; + +internal abstract class ManagedThreadWorkerBase : IAlphaTabWorker +{ + private readonly Action _postToMain; + private readonly Thread _workerThread; + private readonly BlockingCollection _workerQueue; + private readonly CancellationTokenSource _workerCancellationToken; + private readonly ManualResetEventSlim? _threadStartedEvent; + private readonly List>> _listenerInsideWorker = new(); + private readonly List>> _listenerOutsideWorker = new(); + + protected ManagedThreadWorkerBase(Action postToMain) + { + _postToMain = postToMain; + _threadStartedEvent = new ManualResetEventSlim(false); + _workerQueue = new BlockingCollection(); + _workerCancellationToken = new CancellationTokenSource(); + + _workerThread = new Thread(DoWork) + { + IsBackground = true + }; + _workerThread.Start(); + + _threadStartedEvent.Wait(); + _threadStartedEvent.Dispose(); + _threadStartedEvent = null; + } + + protected abstract void OnStartInsideWorker(); + + private void DoWork() + { + _threadStartedEvent.Set(); + OnStartInsideWorker(); + while (_workerQueue.TryTake(out var action, Timeout.Infinite, + _workerCancellationToken.Token)) + { + if (_workerCancellationToken.IsCancellationRequested) + { + break; + } + + action(); + } + } + + + public void PostMessage(T message) + { + var ev = new MessageEvent(message); + if (Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId) + { + // Inside Worker -> Post to main + _postToMain(() => + { + foreach (var listener in _listenerOutsideWorker) + { + listener(ev); + } + }); + } + else + { + // Outside Worker -> Post to worker + PostToWorker(() => + { + foreach (var listener in _listenerInsideWorker) + { + listener(ev); + } + }); + } + } + + public void PostToWorker(Action action) + { + _workerQueue.Add(action); + } + + public void AddEventListener(string @event, Action> handler) + { + var listeners = Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId + ? _listenerInsideWorker + : _listenerOutsideWorker; + if (@event == "message") + { + listeners.Add(handler); + } + } + + public void RemoveEventListener(string @event, Action> handler) + { + var listeners = Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId + ? _listenerInsideWorker + : _listenerOutsideWorker; + if (@event == "message") + { + listeners.Remove(handler); + } + } + + public virtual void Terminate() + { + _workerCancellationToken.Cancel(); + _workerThread.Join(); + } +} + +internal class ManagedThreadAlphaTabRendererWorker : + ManagedThreadWorkerBase, + IAlphaTabRenderingWorker, + IAlphaTabWorkerGlobalScope +{ + private static readonly ConcurrentDictionary + WorkerLookup = new(); + + public static ManagedThreadAlphaTabRendererWorker? CurrentThreadWorker => + WorkerLookup.TryGetValue(Thread.CurrentThread.ManagedThreadId, out var v) ? v : null; + + public ManagedThreadAlphaTabRendererWorker(Action postToMain) : base(postToMain) + { + } + + protected override void OnStartInsideWorker() + { + WorkerLookup[Thread.CurrentThread.ManagedThreadId] = this; + AlphaSynthWebWorker.Init(); + } +} + +internal class ManagedThreadAlphaSynthWorker : + ManagedThreadWorkerBase, + IAlphaSynthWorker, + IAlphaTabWorkerGlobalScope +{ + private static readonly ConcurrentDictionary + WorkerLookup = new(); + + public static ManagedThreadAlphaSynthWorker? CurrentThreadWorker => + WorkerLookup.TryGetValue(Thread.CurrentThread.ManagedThreadId, out var v) ? v : null; + + public ManagedThreadAlphaSynthWorker(Action postToMain) : base(postToMain) + { + } + + protected override void OnStartInsideWorker() + { + WorkerLookup[Thread.CurrentThread.ManagedThreadId] = this; + AlphaTabWebWorker.Init(); + } +} diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorkerApi.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorkerApi.cs deleted file mode 100644 index d1a2ea355..000000000 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorkerApi.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using AlphaTab.Synth; - -namespace AlphaTab.Platform.CSharp; - -internal class ManagedThreadAlphaSynthWorkerApi : AlphaSynthWorkerApiBase -{ - private readonly Action _uiInvoke; - private readonly Thread _workerThread; - private readonly BlockingCollection _workerQueue; - private readonly CancellationTokenSource _workerCancellationToken; - private readonly ManualResetEventSlim? _threadStartedEvent; - - public ManagedThreadAlphaSynthWorkerApi(ISynthOutput output, LogLevel logLevel, Action uiInvoke, double bufferTimeInMilliseconds) - : base(output, logLevel, bufferTimeInMilliseconds) - { - _uiInvoke = uiInvoke; - - _threadStartedEvent = new ManualResetEventSlim(false); - _workerQueue = new BlockingCollection(); - _workerCancellationToken = new CancellationTokenSource(); - - _workerThread = new Thread(DoWork) - { - IsBackground = true - }; - _workerThread.Start(); - - _threadStartedEvent.Wait(); - _workerQueue.Add(Initialize); - _threadStartedEvent.Dispose(); - _threadStartedEvent = null; - } - - public override void Destroy() - { - _workerCancellationToken.Cancel(); - _workerThread.Join(); - } - - protected override void DispatchOnUiThread(Action action) - { - _uiInvoke(action); - } - - private bool CheckAccess() - { - return Thread.CurrentThread == _workerThread; - } - - protected internal override void DispatchOnWorkerThread(Action action) - { - if (CheckAccess()) - { - action(); - } - else - { - _workerQueue.Add(action); - } - } - - private void DoWork() - { - _threadStartedEvent.Set(); - while (_workerQueue.TryTake(out var action, Timeout.Infinite, _workerCancellationToken.Token)) - { - if (_workerCancellationToken.IsCancellationRequested) - { - break; - } - - action(); - } - } -} diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadScoreRenderer.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadScoreRenderer.cs deleted file mode 100644 index 0ba2a22eb..000000000 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadScoreRenderer.cs +++ /dev/null @@ -1,215 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using AlphaTab.Model; -using AlphaTab.Rendering; -using AlphaTab.Rendering.Utils; - -namespace AlphaTab.Platform.CSharp; - -internal class ManagedThreadScoreRenderer : IScoreRenderer -{ - private readonly Action _uiInvoke; - - private readonly Thread _workerThread; - private readonly BlockingCollection _workerQueue; - private readonly ManualResetEventSlim? _threadStartedEvent; - private readonly CancellationTokenSource _workerCancellationToken; - private ScoreRenderer _renderer; - private double _width; - - public BoundsLookup? BoundsLookup { get; private set; } - - public ManagedThreadScoreRenderer(Settings settings, Action uiInvoke) - { - _uiInvoke = uiInvoke; - _renderer = null!; - _threadStartedEvent = new ManualResetEventSlim(false); - _workerQueue = new BlockingCollection(); - _workerCancellationToken = new CancellationTokenSource(); - - _workerThread = new Thread(DoWork) - { - IsBackground = true - }; - _workerThread.Start(); - - _threadStartedEvent.Wait(); - - _workerQueue.Add(() => Initialize(settings)); - _threadStartedEvent.Dispose(); - _threadStartedEvent = null; - } - - - private void DoWork() - { - _threadStartedEvent.Set(); - while (_workerQueue.TryTake(out var action, Timeout.Infinite, - _workerCancellationToken.Token)) - { - if (_workerCancellationToken.IsCancellationRequested) - { - break; - } - - action(); - } - } - - private void Initialize(Settings settings) - { - _renderer = new ScoreRenderer(settings); - _renderer.PartialRenderFinished.On(result => - _uiInvoke(() => OnPartialRenderFinished(result))); - _renderer.PartialLayoutFinished.On(result => - _uiInvoke(() => OnPartialLayoutFinished(result))); - _renderer.RenderFinished.On(result => _uiInvoke(() => OnRenderFinished(result))); - _renderer.PostRenderFinished.On(() => - _uiInvoke(() => OnPostFinished(_renderer.BoundsLookup))); - _renderer.PreRender.On(resize => _uiInvoke(() => OnPreRender(resize))); - _renderer.Error.On(e => _uiInvoke(() => OnError(e))); - } - - private void OnPostFinished(BoundsLookup boundsLookup) - { - BoundsLookup = boundsLookup; - OnPostRenderFinished(); - } - - public void Destroy() - { - _workerCancellationToken.Cancel(); - _workerThread.Join(); - } - - public void UpdateSettings(Settings settings) - { - if (CheckAccess()) - { - _renderer.UpdateSettings(settings); - } - else - { - _workerQueue.Add(() => UpdateSettings(settings)); - } - } - - private bool CheckAccess() - { - return Thread.CurrentThread == _workerThread; - } - - public void Render(RenderHints? renderHints = null) - { - if (CheckAccess()) - { - _renderer.Render(renderHints); - } - else - { - _workerQueue.Add(() => Render(renderHints)); - } - } - - public void RenderResult(string resultId) - { - if (CheckAccess()) - { - _renderer.RenderResult(resultId); - } - else - { - _workerQueue.Add(() => RenderResult(resultId)); - } - } - - public double Width - { - get => _width; - set - { - _width = value; - if (CheckAccess()) - { - _renderer.Width = value; - } - else - { - _workerQueue.Add(() => _renderer.Width = value); - } - } - } - - public void ResizeRender() - { - if (CheckAccess()) - { - _renderer.ResizeRender(); - } - else - { - _workerQueue.Add(ResizeRender); - } - } - - public void RenderScore(Score? score, IList? trackIndexes, RenderHints? renderHints = null) - { - if (CheckAccess()) - { - _renderer.RenderScore(score, trackIndexes, renderHints); - } - else - { - _workerQueue.Add(() => - RenderScore(score, - trackIndexes, renderHints)); - } - } - - public IEventEmitterOfT PreRender { get; } = new EventEmitterOfT(); - - protected virtual void OnPreRender(bool isResize) - { - ((EventEmitterOfT)PreRender).Trigger(isResize); - } - - public IEventEmitterOfT PartialRenderFinished { get; } = - new EventEmitterOfT(); - - protected virtual void OnPartialRenderFinished(RenderFinishedEventArgs obj) - { - ((EventEmitterOfT)PartialRenderFinished).Trigger(obj); - } - - public IEventEmitterOfT PartialLayoutFinished { get; } = - new EventEmitterOfT(); - - protected virtual void OnPartialLayoutFinished(RenderFinishedEventArgs obj) - { - ((EventEmitterOfT)PartialLayoutFinished).Trigger(obj); - } - - public IEventEmitterOfT RenderFinished { get; } = - new EventEmitterOfT(); - - protected virtual void OnRenderFinished(RenderFinishedEventArgs obj) - { - ((EventEmitterOfT)RenderFinished).Trigger(obj); - } - - public IEventEmitterOfT Error { get; } = new EventEmitterOfT(); - - protected virtual void OnError(Error details) - { - ((EventEmitterOfT)Error).Trigger(details); - } - - public IEventEmitter PostRenderFinished { get; } = new EventEmitter(); - - protected virtual void OnPostRenderFinished() - { - ((EventEmitter)PostRenderFinished).Trigger(); - } -} diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedUiFacade.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedUiFacade.cs index 527ee4638..2f5627ecf 100644 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedUiFacade.cs +++ b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedUiFacade.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Concurrent; using System.IO; +using System.Threading; +using System.Threading.Tasks; using AlphaTab.Synth; using AlphaTab.Importer; using AlphaTab.Model; +using AlphaTab.Platform.Worker; using AlphaTab.Rendering; using AlphaTab.Rendering.Utils; @@ -34,41 +37,46 @@ public virtual void Initialize(AlphaTabApiBase api, TSettings setting } public abstract void StopScrolling(IContainer scrollElement); + public abstract void SetCanvasOverflow(IContainer canvasElement, double overflow, bool isVertical); public IScoreRenderer CreateWorkerRenderer() { - return new ManagedThreadScoreRenderer(Api.Settings, BeginInvoke); + var worker = new ManagedThreadAlphaTabRendererWorker(PostToUIThread); + return new AlphaTabWorkerScoreRenderer(Api, worker); } + protected abstract void PostToUIThread(Action action); + protected abstract Stream? OpenDefaultSoundFont(); public IAlphaSynth CreateWorkerPlayer() { - var player = new ManagedThreadAlphaSynthWorkerApi(CreateSynthOutput(), - Api.Settings.Core.LogLevel, BeginInvoke, Api.Settings.Player.BufferTimeInMilliseconds); + var player = new AlphaSynthWebWorkerApi( + CreateSynthOutput(), + Api.Settings, + new ManagedThreadAlphaSynthWorker(PostToUIThread) + ); player.Ready.On(() => { - using (var sf = OpenDefaultSoundFont()) - using (var ms = new MemoryStream()) - { - sf.CopyTo(ms); - player.LoadSoundFont(new Uint8Array(ms.ToArray()), false); - } + using var sf = OpenDefaultSoundFont(); + using var ms = new MemoryStream(); + sf.CopyTo(ms); + player.LoadSoundFont(new Uint8Array(ms.ToArray()), false); }); return player; } public IAudioExporterWorker CreateWorkerAudioExporter(IAlphaSynth? synth) { - var needNewWorker = synth == null || synth is not ManagedThreadAlphaSynthWorkerApi; + var needNewWorker = synth is not AlphaSynthWebWorkerApi; if (needNewWorker) { synth = CreateWorkerPlayer(); } - return new ManagedThreadAlphaSynthAudioExporter((ManagedThreadAlphaSynthWorkerApi)synth, needNewWorker); + return new AlphaSynthAudioExporterWorkerApi(synth as AlphaSynthWebWorkerApi, needNewWorker); } public abstract IAlphaSynth? CreateBackingTrackPlayer(); @@ -86,8 +94,7 @@ public abstract void TriggerEvent(IContainer container, string eventName, public virtual void InitialRender() { - Api.Renderer.PreRender.On(resize => { TotalResultCount.Enqueue(new Counter()); }); - + Api.Renderer.PreRender.On(_ => { TotalResultCount.Enqueue(new Counter()); }); RootContainerBecameVisible.On(() => { @@ -106,7 +113,45 @@ public virtual void InitialRender() public abstract void BeginUpdateRenderResults(RenderFinishedEventArgs? renderResults); public abstract void DestroyCursors(); public abstract Cursors? CreateCursors(); - public abstract void BeginInvoke(Action action); + + public void BeginInvoke(Action action) + { + // post to "own" event loop if running inside worker + var synthWorker = ManagedThreadAlphaSynthWorker.CurrentThreadWorker; + if (synthWorker != null) + { + synthWorker.PostToWorker(action); + return; + } + + var renderWorker = ManagedThreadAlphaTabRendererWorker.CurrentThreadWorker; + if (renderWorker != null) + { + renderWorker.PostToWorker(action); + return; + } + + // not in worker -> run on main + PostToUIThread(action); + } + + public Action Throttle(Action action, double delay) + { + CancellationTokenSource? cancellationTokenSource = null; + return () => + { + cancellationTokenSource?.Cancel(); + cancellationTokenSource = new CancellationTokenSource(); + + Task.Run(async () => + { + await Task.Delay((int)delay, cancellationTokenSource.Token); + PostToUIThread(action); + }, + cancellationTokenSource.Token); + }; + } + public abstract void RemoveHighlights(); public abstract void HighlightElements(string groupId, double masterBarIndex); public abstract IContainer? CreateSelectionElement(); @@ -126,16 +171,14 @@ public bool Load(object? data, Action success, Action error) success(ScoreLoader.LoadScoreFromBytes(new Uint8Array(b), Api.Settings)); return true; case Stream s: - { - using (var ms = new MemoryStream()) - { - s.CopyTo(ms); - success(ScoreLoader.LoadScoreFromBytes(new Uint8Array(ms.ToArray()), - Api.Settings)); - } - - return true; - } + { + using var ms = new MemoryStream(); + s.CopyTo(ms); + success(ScoreLoader.LoadScoreFromBytes(new Uint8Array(ms.ToArray()), + Api.Settings)); + + return true; + } default: return false; } @@ -149,15 +192,13 @@ public bool LoadSoundFont(object? data, bool append) Api.Player.LoadSoundFont(new Uint8Array(bytes), append); return true; case Stream stream: - { - using (var ms = new MemoryStream()) - { - stream.CopyTo(ms); - Api.Player.LoadSoundFont(new Uint8Array(ms.ToArray()), append); - } - - return true; - } + { + using var ms = new MemoryStream(); + stream.CopyTo(ms); + Api.Player.LoadSoundFont(new Uint8Array(ms.ToArray()), append); + + return true; + } default: return false; } diff --git a/packages/transpiler/src/csharp/CSharpAst.ts b/packages/transpiler/src/csharp/CSharpAst.ts index 09e2714db..e7c2d17f2 100644 --- a/packages/transpiler/src/csharp/CSharpAst.ts +++ b/packages/transpiler/src/csharp/CSharpAst.ts @@ -161,7 +161,7 @@ export interface ClassDeclaration extends NamedTypeDeclaration { interfaces?: TypeNode[]; isAbstract: boolean; members: ClassMember[]; - isRecord?:boolean; + isRecord?: boolean; } export type ClassMember = @@ -458,6 +458,7 @@ export interface NewExpression extends Node { nodeType: SyntaxKind.NewExpression; type: TypeNode; arguments: Expression[]; + objectInitializers?: LabeledExpression[]; } export interface CastExpression extends Node { diff --git a/packages/transpiler/src/csharp/CSharpAstPrinter.ts b/packages/transpiler/src/csharp/CSharpAstPrinter.ts index 37a863382..0b0f28ffa 100644 --- a/packages/transpiler/src/csharp/CSharpAstPrinter.ts +++ b/packages/transpiler/src/csharp/CSharpAstPrinter.ts @@ -879,6 +879,19 @@ export default class CSharpAstPrinter extends AstPrinterBase { this.write('('); this.writeCommaSeparated(expr.arguments, a => this.writeExpression(a)); this.write(')'); + if (expr.objectInitializers?.length) { + this.writeLine(); + this.beginBlock(); + + for (const a of expr.objectInitializers!) { + this.write(a.label); + this.write(' = '); + this.writeExpression(a.expression); + this.writeLine(","); + } + + this.endBlock(); + } } protected writeCastExpression(expr: cs.CastExpression) { @@ -1068,7 +1081,7 @@ export default class CSharpAstPrinter extends AstPrinterBase { } protected writeUsing(using: cs.UsingDeclaration) { - if (using.skipEmit) { + if (using.skipEmit) { return; } @@ -1105,7 +1118,7 @@ export default class CSharpAstPrinter extends AstPrinterBase { protected override writeThrowStatement(s: cs.ThrowStatement) { this.write('throw'); - const currentException = this._currentCatchClauseIdentifier[this._currentCatchClauseIdentifier.length -1]; + const currentException = this._currentCatchClauseIdentifier[this._currentCatchClauseIdentifier.length - 1]; if (s.expression && (!cs.isIdentifier(s.expression) || s.expression.text !== currentException)) { this.write(' '); this.writeExpression(s.expression); diff --git a/packages/transpiler/src/csharp/CSharpAstTransformer.ts b/packages/transpiler/src/csharp/CSharpAstTransformer.ts index 8c94a04f2..3e835091e 100644 --- a/packages/transpiler/src/csharp/CSharpAstTransformer.ts +++ b/packages/transpiler/src/csharp/CSharpAstTransformer.ts @@ -268,7 +268,7 @@ export default class CSharpAstTransformer { } // TODO: make root namespace configurable from outside. - const folders = path + const folders: string[] = path .dirname( path.relative( path.resolve(this.context.compilerOptions.baseUrl!), @@ -427,6 +427,8 @@ export default class CSharpAstTransformer { this.visitFunctionTypeAliasDeclaration(node); } else if (ts.isTypeLiteralNode(node.type)) { this.visitTypeLiteralAliasDeclaration(node); + } else if (this.context.isDiscriminatedUnion(node)) { + this.visitDiscriminatedUnion(node); } else if (isExported && !shouldSkip) { this.context.addTsNodeDiagnostics( node, @@ -504,6 +506,186 @@ export default class CSharpAstTransformer { this.visitRecordDeclaration(node); } + protected visitDiscriminatedUnion(node: ts.TypeAliasDeclaration) { + const tag = ts.getJSDocTags(node).find(t => t.tagName.text === 'discriminated')!; + const values = (tag.comment as string).split(' '); + const discriminatorField = values[0]; + const discriminatorValuePrefix = values[1]; + + const unionType = this.context.typeChecker.getTypeAtLocation(node.type); + if (!unionType.isUnion()) { + this.context.addTsNodeDiagnostics( + node, + `Discriminated union must be a union type`, + ts.DiagnosticCategory.Error + ); + return; + } + + // Create base interface + const baseInterface: cs.InterfaceDeclaration = { + visibility: this.getVisibility(node), + name: node.name.text, + nodeType: cs.SyntaxKind.InterfaceDeclaration, + parent: this.csharpFile.namespace, + members: [], + tsNode: node, + skipEmit: this.shouldSkip(node, false), + partial: false, + tsSymbol: this.context.getSymbolForDeclaration(node), + hasVirtualMembersOrSubClasses: false + }; + + if (node.name) { + baseInterface.documentation = this.visitDocumentation(node.name); + } + + this._visitDocumentationAttributes(baseInterface, node); + + // Add discriminator property to interface + const discriminatorProperty: cs.PropertyDeclaration = { + visibility: cs.Visibility.Public, + name: this.context.toPropertyName(discriminatorField), + nodeType: cs.SyntaxKind.PropertyDeclaration, + parent: baseInterface, + isVirtual: false, + isOverride: false, + isAbstract: false, + isStatic: false, + type: this.createUnresolvedTypeNode(null, node.type, this.context.typeChecker.getStringType()), + tsNode: node, + getAccessor: { + keyword: 'get' + } as cs.PropertyAccessorDeclaration, + setAccessor: { + keyword: 'set' + } as cs.PropertyAccessorDeclaration, + skipEmit: false + }; + + baseInterface.members.push(discriminatorProperty); + + this.csharpFile.namespace.declarations.push(baseInterface); + this.context.registerSymbol(baseInterface); + + const typeNamePrefix = baseInterface.name.startsWith('I') + ? baseInterface.name.substring(1) + : baseInterface.name; + + // Create classes for each union member + for (const memberType of unionType.types) { + const properties = this.context.typeChecker.getPropertiesOfType(memberType); + const discriminatorProp = properties.find(p => p.name === discriminatorField); + if (!discriminatorProp) { + continue; + } + + const discriminatorType = this.context.typeChecker.getTypeOfSymbolAtLocation(discriminatorProp, node); + if (!discriminatorType.isStringLiteral()) { + continue; + } + + const discriminatorValue = discriminatorType.value; + + // Compute class name + const suffix = discriminatorValue.startsWith(discriminatorValuePrefix) + ? discriminatorValue.substring(discriminatorValuePrefix.length) + : discriminatorValue; + const className = + typeNamePrefix + + suffix + .split('.') + .map(p => this.context.toPascalCase(p)) + .join(''); + + // Create class + const csClass: cs.ClassDeclaration = { + visibility: this.getVisibility(node), + name: className, + nodeType: cs.SyntaxKind.ClassDeclaration, + parent: this.csharpFile.namespace, + isAbstract: false, + members: [], + skipEmit: this.shouldSkip(node, false), + partial: false, + tsNode: memberType.symbol.declarations![0], + tsSymbol: memberType.symbol, + hasVirtualMembersOrSubClasses: false, + isRecord: true + }; + + // Add interface implementation + csClass.interfaces = [ + { + nodeType: cs.SyntaxKind.TypeReference, + parent: csClass, + reference: this.context.makeTypeName(baseInterface.name), + isAsync: false + } as cs.TypeReference + ]; + + // Add discriminator property + const discProp: cs.PropertyDeclaration = { + visibility: cs.Visibility.Public, + name: this.context.toPropertyName(discriminatorField), + nodeType: cs.SyntaxKind.PropertyDeclaration, + parent: csClass, + isVirtual: false, + isOverride: false, + isAbstract: false, + isStatic: false, + type: this.createUnresolvedTypeNode(null, node.type, this.context.typeChecker.getStringType()), + initializer: { + nodeType: cs.SyntaxKind.StringLiteral, + text: discriminatorValue + } as cs.StringLiteral, + getAccessor: { + keyword: 'get' + } as cs.PropertyAccessorDeclaration, + setAccessor: { + keyword: 'set' + } as cs.PropertyAccessorDeclaration, + tsNode: node, + skipEmit: false + }; + discProp.initializer!.parent = discProp; + + csClass.members.push(discProp); + + // Add other properties + const otherProperties = properties.filter(p => p.name !== discriminatorField); + for (const prop of otherProperties) { + const propType = this.context.typeChecker.getTypeOfSymbolAtLocation(prop, node); + + // Create property + const csProperty: cs.PropertyDeclaration = { + visibility: cs.Visibility.Public, + name: this.context.toPropertyName(prop.name), + nodeType: cs.SyntaxKind.PropertyDeclaration, + parent: csClass, + isVirtual: false, + isOverride: false, + isAbstract: false, + isStatic: false, + type: this.createUnresolvedTypeNode(null, node.type, propType), + tsNode: node, + getAccessor: { + keyword: 'get' + } as cs.PropertyAccessorDeclaration, + setAccessor: { + keyword: 'set' + } as cs.PropertyAccessorDeclaration, + skipEmit: false + }; + + csClass.members.push(csProperty); + } + + this.csharpFile.namespace.declarations.push(csClass); + this.context.registerSymbol(csClass); + } + } + protected visitInterfaceDeclaration(node: ts.InterfaceDeclaration) { if (this.context.isRecord(node)) { this.visitRecordDeclaration(node); @@ -2371,13 +2553,17 @@ export default class CSharpAstTransformer { } protected visitReturnStatement(parent: cs.Node, s: ts.ReturnStatement) { - if(this.currentClassElement && ts.isMethodDeclaration(this.currentClassElement) && !!this.currentClassElement.asteriskToken) { + if ( + this.currentClassElement && + ts.isMethodDeclaration(this.currentClassElement) && + !!this.currentClassElement.asteriskToken + ) { const yieldExpressionStmt = { expression: null!, nodeType: cs.SyntaxKind.ExpressionStatement, parent: parent, tsNode: s - } as cs.ExpressionStatement + } as cs.ExpressionStatement; const yieldExpression = { expression: null, parent: yieldExpressionStmt, @@ -4095,6 +4281,13 @@ export default class CSharpAstTransformer { protected visitObjectLiteralExpression(parent: cs.Node, expression: ts.ObjectLiteralExpression) { let type = this.context.typeChecker.getContextualType(expression); let isRecord = type?.symbol?.declarations?.some(d => this.context.isRecord(d)) || this._recordCreation > 0; + const isDiscriminatedUnion = + type?.symbol?.declarations?.some(d => this.context.isDiscriminatedUnion(d)) || + type?.aliasSymbol?.declarations?.some(d => this.context.isDiscriminatedUnion(d)); + + if (isDiscriminatedUnion) { + return this.visitDiscriminatedUnionCreate(parent, expression); + } // assignment of object literal to property without giving type // -> try to use specific type of property @@ -4332,6 +4525,93 @@ export default class CSharpAstTransformer { return objectLiteral; } + protected visitDiscriminatedUnionCreate(parent: cs.Node, expression: ts.ObjectLiteralExpression) { + const unionType = this.context.typeChecker.getContextualType(expression)! as ts.UnionType; + + // find concrete type within unionType with which the expression matches + + const tag = ts + .getJSDocTags(unionType.aliasSymbol!.declarations![0]) + .find(t => t.tagName.text === 'discriminated')!; + const values = (tag.comment as string).split(' '); + const discriminatorField = values[0]; + + const discriminatorProp = expression.properties.find( + p => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === discriminatorField + ) as ts.PropertyAssignment; + + const discriminatorValue = ts.isStringLiteral(discriminatorProp!.initializer) ? discriminatorProp.initializer.text : undefined; + + const matching = unionType.types.find(memberType => { + const prop = memberType.getProperty(discriminatorField); + if (!prop) {return false;} + const propType = this.context.typeChecker.getTypeOfSymbolAtLocation(prop, expression); + return propType.flags & ts.TypeFlags.StringLiteral && (propType as ts.StringLiteralType).value === discriminatorValue; + }); + + if(!matching) { + this.context.addCsNodeDiagnostics(parent, "Could not resolve concrete union type", ts.DiagnosticCategory.Error); + return null; + } + + const newObject = { + nodeType: cs.SyntaxKind.NewExpression, + type: this.createUnresolvedTypeNode(null, expression, matching), + arguments: [], + parent: parent, + objectInitializers: [] + } as cs.NewExpression; + + for (const p of expression.properties) { + const assignment = { + parent: newObject, + nodeType: cs.SyntaxKind.LabeledExpression, + label: '', + expression: {} as cs.Expression + } as cs.LabeledExpression; + + if (ts.isPropertyAssignment(p)) { + assignment.label = this.context.toPropertyName(p.name.getText()); + assignment.expression = this.visitExpression(assignment, p.initializer)!; + newObject.objectInitializers!.push(assignment); + } else if (ts.isShorthandPropertyAssignment(p)) { + assignment.label = this.context.toPropertyName(p.name.getText()); + if (p.objectAssignmentInitializer) { + assignment.expression = this.visitExpression(assignment, p.objectAssignmentInitializer)!; + } else { + assignment.expression = { + nodeType: cs.SyntaxKind.Identifier, + parent: assignment, + tsNode: p.name, + text: p.name.getText() + } as cs.Identifier; + } + newObject.objectInitializers!.push(assignment); + } else if (ts.isSpreadAssignment(p)) { + this.context.addTsNodeDiagnostics(p, 'Spread operator not supported', ts.DiagnosticCategory.Error); + } else if (ts.isMethodDeclaration(p)) { + this.context.addTsNodeDiagnostics( + p, + 'Method declarations in object literals not supported', + ts.DiagnosticCategory.Error + ); + } else if (ts.isGetAccessorDeclaration(p)) { + this.context.addTsNodeDiagnostics( + p, + 'Get accessor declarations in object literals not supported', + ts.DiagnosticCategory.Error + ); + } else if (ts.isSetAccessorDeclaration(p)) { + this.context.addTsNodeDiagnostics( + p, + 'Set accessor declarations in object literals not supported', + ts.DiagnosticCategory.Error + ); + } + } + + return newObject; + } protected toInvariantString(expr: cs.Expression): cs.Expression { const callExpr = { diff --git a/packages/transpiler/src/csharp/CSharpEmitterContext.ts b/packages/transpiler/src/csharp/CSharpEmitterContext.ts index 49ab75bf8..37f94d449 100644 --- a/packages/transpiler/src/csharp/CSharpEmitterContext.ts +++ b/packages/transpiler/src/csharp/CSharpEmitterContext.ts @@ -346,6 +346,11 @@ export default class CSharpEmitterContext { (ts.isTypeAliasDeclaration(d) && ts.isTypeLiteralNode(d.type)) ); } + + isDiscriminatedUnion(node: ts.Declaration) { + return ts.getJSDocTags(node).some(t => t.tagName.text === 'discriminated'); + } + private getTypeFromTsType( node: cs.Node, tsType: ts.Type, @@ -821,6 +826,13 @@ export default class CSharpEmitterContext { return null; } + if ('typeArguments' in pTsType && cs.isTypeReference(pType)) { + const args = this.typeChecker.getTypeArguments(pTsType as ts.TypeReference); + if (args.length > 0) { + pType.typeArguments = args.map(a => this.getTypeFromTsType(pType, a)!); + } + } + parameterTypes.push(pType); } @@ -1387,7 +1399,7 @@ export default class CSharpEmitterContext { } public registerSymbol(node: cs.NamedElement & cs.Node) { - const symbol = this.getSymbolForDeclaration(node.tsNode!); + const symbol = node.tsSymbol ?? this.getSymbolForDeclaration(node.tsNode!); if (symbol) { const symbolKey = this.getSymbolKey(symbol); this._symbolLookup.set(symbolKey, node); @@ -1426,10 +1438,15 @@ export default class CSharpEmitterContext { ? symbol.declarations[0] : undefined; + let name = symbol.name; + if (name.startsWith('__')) { + name = '__'; + } + if (declaration) { - return `${symbol.name}_${declaration.getSourceFile().fileName}_${declaration.pos}`; + return `${name}_${declaration.getSourceFile().fileName}_${declaration.pos}`; } - return symbol.name; + return name; } public getSymbolForDeclaration(node: ts.Node): ts.Symbol | undefined { @@ -1814,9 +1831,16 @@ export default class CSharpEmitterContext { if (contextualType.symbol) { switch (contextualType.symbol.name) { case 'ArrayLike': - case '__type': return true; } + + // empty object type {} (basically object) + if ( + contextualType.flags & ts.TypeFlags.Object && + (contextualType as ts.ObjectType).getProperties().length === 0 + ) { + return true; + } } return false; From b25b6d99bb546fac6069b0d1158d1aa4e69cc9c7 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Wed, 8 Apr 2026 21:33:07 +0200 Subject: [PATCH 04/11] refactor(kotlin): use worker style components --- packages/alphatab/src/Environment.ts | 1 + .../AlphaSynthAudioExporterWorkerApi.ts | 5 +- .../src/platform/worker/AlphaTabWebWorker.ts | 2 +- packages/alphatab/src/synth/IAudioExporter.ts | 2 + .../CSharp/ManagedThreadAlphaSynthWorker.cs | 16 +- .../main/java/alphaTab/EnvironmentPartials.kt | 36 +- .../alphaTab/core/ecmaScript/MessageEvent.kt | 3 + .../java/alphaTab/core/ecmaScript/Promise.kt | 10 + .../core/ecmaScript/PromiseWithResolvers.kt | 19 + .../platform/android/AndroidSynthOutput.kt | 15 +- .../AndroidThreadAlphaSynthAudioExporter.kt | 92 ----- .../AndroidThreadAlphaSynthWorkerPlayer.kt | 344 ------------------ .../android/AndroidThreadScoreRenderer.kt | 188 ---------- .../platform/android/AndroidUiFacade.kt | 79 +++- .../platform/android/JavaThreadWorkers.kt | 150 ++++++++ packages/transpiler/src/AstPrinterBase.ts | 2 +- .../src/csharp/CSharpAstTransformer.ts | 332 +++++++++-------- .../src/csharp/CSharpEmitterContext.ts | 65 ++-- .../transpiler/src/kotlin/KotlinAstPrinter.ts | 39 +- .../src/kotlin/KotlinAstTransformer.ts | 67 +++- .../src/kotlin/KotlinEmitterContext.ts | 25 +- 21 files changed, 624 insertions(+), 868 deletions(-) create mode 100644 packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/MessageEvent.kt create mode 100644 packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/PromiseWithResolvers.kt delete mode 100644 packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadAlphaSynthAudioExporter.kt delete mode 100644 packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadAlphaSynthWorkerPlayer.kt delete mode 100644 packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadScoreRenderer.kt create mode 100644 packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt diff --git a/packages/alphatab/src/Environment.ts b/packages/alphatab/src/Environment.ts index e207a2db0..92d0cea78 100644 --- a/packages/alphatab/src/Environment.ts +++ b/packages/alphatab/src/Environment.ts @@ -174,6 +174,7 @@ export class Environment { /** * @target web * @internal + * @partial */ public static getGlobalWorkerScope(): IAlphaTabWorkerGlobalScope { return Environment.globalThis; diff --git a/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts b/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts index 98c140501..6f17c4bf9 100644 --- a/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts @@ -21,7 +21,7 @@ export class AlphaSynthAudioExporterWorkerApi implements IAudioExporterWorker { private _exporterId: number; private _ownsWorker: boolean; - private _promise: PromiseWithResolvers | null = null; + private _promise: PromiseWithResolvers | null = null; public constructor(synthWorker: AlphaSynthWebWorkerApi, ownsWorker: boolean) { this._exporterId = AlphaSynthAudioExporterWorkerApi._nextExporterId++; @@ -103,7 +103,8 @@ export class AlphaSynthAudioExporterWorkerApi implements IAudioExporterWorker { exporterId: this._exporterId, milliseconds: milliseconds }); - return (await this._promise.promise) as AudioExportChunk | undefined; + const result = await this._promise.promise; + return result as AudioExportChunk | undefined; } destroy(): void { diff --git a/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts b/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts index 9c0220006..191cdbceb 100644 --- a/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts @@ -85,7 +85,7 @@ export class AlphaTabWebWorker { break; case 'alphaTab.renderScore': this._updateFontSizes(data.fontSizes); - const score: any = + const score = data.score == null ? null : JsonConverter.jsObjectToScore(data.score, this._renderer.settings); this._renderMultiple(score, data.trackIndexes); break; diff --git a/packages/alphatab/src/synth/IAudioExporter.ts b/packages/alphatab/src/synth/IAudioExporter.ts index 5dd29603e..5523b451e 100644 --- a/packages/alphatab/src/synth/IAudioExporter.ts +++ b/packages/alphatab/src/synth/IAudioExporter.ts @@ -119,6 +119,7 @@ export interface IAudioExporter extends Disposable { * slightly longer audio is contained in the result. * * When the song ends, the chunk might contain less than the requested duration. + * @async */ render(milliseconds: number): Promise; @@ -141,6 +142,7 @@ export interface IAudioExporterWorker extends IAudioExporter { * @param midi The midi file to load * @param syncPoints The sync points of the song (if any) * @param transpositionPitches The initial transposition pitches for the midi file. + * @async */ initialize( options: AudioExportOptions, diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs index ba10fcf54..c7d7ace5f 100644 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs +++ b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs @@ -87,24 +87,20 @@ public void PostToWorker(Action action) public void AddEventListener(string @event, Action> handler) { + if (@event != "message") return; var listeners = Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId ? _listenerInsideWorker : _listenerOutsideWorker; - if (@event == "message") - { - listeners.Add(handler); - } + listeners.Add(handler); } public void RemoveEventListener(string @event, Action> handler) { + if (@event != "message") return; var listeners = Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId ? _listenerInsideWorker : _listenerOutsideWorker; - if (@event == "message") - { - listeners.Remove(handler); - } + listeners.Remove(handler); } public virtual void Terminate() @@ -132,7 +128,7 @@ public ManagedThreadAlphaTabRendererWorker(Action postToMain) : base(pos protected override void OnStartInsideWorker() { WorkerLookup[Thread.CurrentThread.ManagedThreadId] = this; - AlphaSynthWebWorker.Init(); + AlphaTabWebWorker.Init(); } } @@ -154,6 +150,6 @@ public ManagedThreadAlphaSynthWorker(Action postToMain) : base(postToMai protected override void OnStartInsideWorker() { WorkerLookup[Thread.CurrentThread.ManagedThreadId] = this; - AlphaTabWebWorker.Init(); + AlphaSynthWebWorker.Init(); } } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt b/packages/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt index d3f534bcc..ce7868fde 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt @@ -4,14 +4,16 @@ import alphaTab.collections.DoubleList import alphaTab.platform.Json import alphaTab.platform.android.AndroidCanvas import alphaTab.platform.android.AndroidEnvironment +import alphaTab.platform.android.JavaThreadAlphaSynthWorker +import alphaTab.platform.android.JavaThreadAlphaTabRendererWorker +import alphaTab.platform.worker.IAlphaSynthWorkerMessage +import alphaTab.platform.worker.IAlphaTabWorkerGlobalScope +import alphaTab.platform.worker.IAlphaTabWorkerMessage +import alphaTab.synth.AudioExportOptions import android.os.Build -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import kotlin.contracts.ExperimentalContracts + @ExperimentalContracts @ExperimentalUnsignedTypes internal class EnvironmentPartials { @@ -27,9 +29,6 @@ internal class EnvironmentPartials { ) } - internal fun platformInit() { - } - internal fun _printPlatformInfo(print: (message: String) -> Unit) { print("OS Name: ${System.getProperty("os.name")}"); print("OS Version: ${System.getProperty("os.version")}"); @@ -39,24 +38,21 @@ internal class EnvironmentPartials { print("Screen Size: ${AndroidEnvironment.screenWidth}x${AndroidEnvironment.screenHeight}"); } - private val throttleScope = CoroutineScope(Dispatchers.Default) - internal fun throttle(toThrottle: () -> Unit, delay: Double): () -> Unit { - var job: Job? = null - return { - job?.cancel() - job = throttleScope.launch { - delay(delay.toLong()) - toThrottle() - } - } - } - @Suppress("NOTHING_TO_INLINE") internal inline fun quoteJsonString(string: String) = Json.quoteJsonString(string) @Suppress("NOTHING_TO_INLINE") internal inline fun sortDescending(list: DoubleList) = list.sortDescending() + internal inline fun getGlobalWorkerScope(): IAlphaTabWorkerGlobalScope { + @Suppress("UNCHECKED_CAST") + return when (T::class) { + IAlphaSynthWorkerMessage::class::class -> JavaThreadAlphaSynthWorker.currentThreadWorker as IAlphaTabWorkerGlobalScope + IAlphaTabWorkerMessage::class::class -> JavaThreadAlphaTabRendererWorker.currentThreadWorker as IAlphaTabWorkerGlobalScope + else -> throw UnsupportedOperationException("Unsupported worker scope kind") + } + } + inline fun prepareForPostMessage(v:T) = v } } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/MessageEvent.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/MessageEvent.kt new file mode 100644 index 000000000..acf26ace0 --- /dev/null +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/MessageEvent.kt @@ -0,0 +1,3 @@ +package alphaTab.core.ecmaScript + +internal class MessageEvent(val data: T) diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Promise.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Promise.kt index dc4619e93..21a1e582f 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Promise.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/Promise.kt @@ -1,5 +1,7 @@ package alphaTab.core.ecmaScript +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.async import kotlinx.coroutines.cancelChildren @@ -20,5 +22,13 @@ internal class Promise { } } } + + fun withResolvers(): PromiseWithResolvers { + return PromiseWithResolvers(); + } + + fun resolve(value: T): Deferred { + return CompletableDeferred(value) + } } } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/PromiseWithResolvers.kt b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/PromiseWithResolvers.kt new file mode 100644 index 000000000..5de2b4765 --- /dev/null +++ b/packages/kotlin/src/android/src/main/java/alphaTab/core/ecmaScript/PromiseWithResolvers.kt @@ -0,0 +1,19 @@ +package alphaTab.core.ecmaScript + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred + +class PromiseWithResolvers { + private val _deferred = CompletableDeferred() + + public val promise: Deferred + get() = _deferred + + fun resolve(v: T) { + _deferred.complete(v) + } + + fun reject(e: Error) { + _deferred.completeExceptionally(e) + } +} diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidSynthOutput.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidSynthOutput.kt index fb18f5d30..0bd11f42e 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidSynthOutput.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidSynthOutput.kt @@ -19,8 +19,7 @@ import kotlin.math.min @ExperimentalUnsignedTypes @ExperimentalContracts internal class AndroidSynthOutput( - private val context: Context, - private val synthInvoke: (action: (() -> Unit)) -> Unit + private val context: Context ) : ISynthOutput { companion object { private const val BufferSize = 4096 @@ -56,9 +55,7 @@ internal class AndroidSynthOutput( } private fun onReady() { - synthInvoke { - (ready as EventEmitter).trigger() - } + (ready as EventEmitter).trigger() } override fun destroy() { @@ -103,15 +100,11 @@ internal class AndroidSynthOutput( } private fun onSampleRequest() { - synthInvoke { - (sampleRequest as EventEmitter).trigger() - } + (sampleRequest as EventEmitter).trigger() } internal fun onSamplesPlayed(samples: Int) { - synthInvoke { - (samplesPlayed as EventEmitterOfT).trigger(samples.toDouble()) - } + (samplesPlayed as EventEmitterOfT).trigger(samples.toDouble()) } fun read(buffer: FloatArray, offset: Int, sampleCount: Int): Int { diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadAlphaSynthAudioExporter.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadAlphaSynthAudioExporter.kt deleted file mode 100644 index 631234e97..000000000 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadAlphaSynthAudioExporter.kt +++ /dev/null @@ -1,92 +0,0 @@ -package alphaTab.platform.android - -import alphaTab.AlphaTabError -import alphaTab.AlphaTabErrorType -import alphaTab.collections.DoubleDoubleMap -import alphaTab.collections.List -import alphaTab.midi.MidiFile -import alphaTab.synth.IAlphaSynthAudioExporter -import alphaTab.synth.AudioExportChunk -import alphaTab.synth.AudioExportOptions -import alphaTab.synth.BackingTrackSyncPoint -import alphaTab.synth.IAudioExporterWorker -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Deferred -import kotlin.contracts.ExperimentalContracts - -@ExperimentalContracts -@ExperimentalUnsignedTypes -internal class AndroidThreadAlphaSynthAudioExporter( - private val worker: AndroidThreadAlphaSynthWorkerPlayer, - private val ownsWorker: Boolean -) : IAudioExporterWorker { - - private var _exporter: IAlphaSynthAudioExporter? = null - private var _deferred: Deferred<*>? = null - - override fun initialize( - options: AudioExportOptions, - midi: MidiFile, - syncPoints: List, - transpositionPitches: DoubleDoubleMap - ): Deferred { - return dispatchAsyncOnWorkerThread { - val player = worker.player - ?: throw AlphaTabError( - AlphaTabErrorType.General, - "The player was destroyed prematurely" - ) - _exporter = player.exportAudio( - options, - midi, - syncPoints, - transpositionPitches - ) - } - } - - override fun render(milliseconds: Double): Deferred { - return dispatchAsyncOnWorkerThread { - val exporter = _exporter - ?: throw AlphaTabError( - AlphaTabErrorType.General, - "The exporter was destroyed prematurely" - ) - exporter.render(milliseconds) - } - } - - override fun destroy() { - _exporter = null - if (ownsWorker) { - worker.destroy() - } - } - - override fun close() { - destroy() - } - - private fun dispatchAsyncOnWorkerThread(action: () -> T): Deferred { - if (_deferred != null) { - throw AlphaTabError( - AlphaTabErrorType.General, - "There is already an ongoing operation, wait for previous operation to complete before proceeding" - ); - } - - val deferred = CompletableDeferred() - _deferred = deferred - worker.addToWorker { - try { - deferred.complete(action()) - } catch (e: Throwable) { - deferred.completeExceptionally(e) - } finally { - _deferred = null - } - } - return deferred - } - -} diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadAlphaSynthWorkerPlayer.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadAlphaSynthWorkerPlayer.kt deleted file mode 100644 index a31d033dc..000000000 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadAlphaSynthWorkerPlayer.kt +++ /dev/null @@ -1,344 +0,0 @@ -package alphaTab.platform.android - -import alphaTab.* -import alphaTab.EventEmitter -import alphaTab.collections.List -import alphaTab.core.ecmaScript.Error -import alphaTab.core.ecmaScript.Uint8Array -import alphaTab.midi.MidiEventType -import alphaTab.midi.MidiFile -import alphaTab.model.Score -import alphaTab.synth.* -import android.util.Log -import java.util.concurrent.BlockingQueue -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.Semaphore -import java.util.concurrent.TimeUnit -import kotlin.contracts.ExperimentalContracts - -@ExperimentalUnsignedTypes -@ExperimentalContracts -internal class AndroidThreadAlphaSynthWorkerPlayer : IAlphaSynth, Runnable { - private val _uiInvoke: (action: (() -> Unit)) -> Unit - - private val _workerThread: Thread - private val _workerQueue: BlockingQueue<() -> Unit> - private val _threadStartedEvent: Semaphore - private var _isCancelled = false - - private var _player: AlphaSynth? = null - private val _output: ISynthOutput - private var _logLevel: LogLevel - private var _bufferTimeInMilliseconds: Double - - val player: AlphaSynth? - get() = _player - - override val output: ISynthOutput - get() = _output - - constructor( - logLevel: LogLevel, - output: ISynthOutput, - uiInvoke: (action: (() -> Unit)) -> Unit, - bufferTimeInMilliseconds: Double - ) { - _logLevel = logLevel - _bufferTimeInMilliseconds = bufferTimeInMilliseconds - _output = output - _uiInvoke = uiInvoke - _threadStartedEvent = Semaphore(1) - _threadStartedEvent.acquire() - _workerQueue = LinkedBlockingQueue() - - _workerThread = Thread(this) - _workerThread.name = "alphaSynthWorkerThread" - _workerThread.isDaemon = true - _workerThread.start() - - _threadStartedEvent.acquire() - - _workerQueue.add { initialize() } - } - - public fun addToWorker(action: () -> Unit) { - _workerQueue.add(action) - } - - override fun destroy() { - _isCancelled = true - _workerThread.interrupt() - _workerThread.join() - } - - override fun run() { - _threadStartedEvent.release() - try { - Log.d("AlphaTab", "AlphaSynth worker started") - do { - val item = _workerQueue.poll(500, TimeUnit.MILLISECONDS) - if (!_isCancelled && item != null) { - item() - } - } while (!_isCancelled) - } catch (e: InterruptedException) { - Log.d("AlphaTab", "AlphaSynth worker stopped") - // finished - } - } - - private fun initialize() { - val player = AlphaSynth(_output, _bufferTimeInMilliseconds) - _player = player - player.positionChanged.on { - _uiInvoke { onPositionChanged(it) } - } - player.stateChanged.on { - _uiInvoke { onStateChanged(it) } - } - player.finished.on { - _uiInvoke { onFinished() } - } - player.soundFontLoaded.on { - _uiInvoke { onSoundFontLoaded() } - } - player.soundFontLoadFailed.on { - _uiInvoke { onSoundFontLoadFailed(it) } - } - player.midiLoaded.on { - _uiInvoke { onMidiLoaded(it) } - } - player.midiLoadFailed.on { - _uiInvoke { onMidiLoadFailed(it) } - } - player.readyForPlayback.on { - _uiInvoke { onReadyForPlayback() } - } - player.midiEventsPlayed.on { - _uiInvoke { onMidiEventsPlayed(it) } - } - player.playbackRangeChanged.on { - _uiInvoke { onPlaybackRangeChanged(it) } - } - - _uiInvoke { onReady() } - } - - override val isReady: Boolean - get() = _player?.isReady ?: false - - override val isReadyForPlayback: Boolean - get() = _player?.isReadyForPlayback ?: false - - override val state: PlayerState - get() = _player?.state ?: PlayerState.Paused - - override var logLevel: LogLevel - get() = _logLevel - set(value) { - _logLevel = value - _workerQueue.add { _player?.logLevel = value } - } - - override var masterVolume: Double - get() = _player?.masterVolume ?: 0.0 - set(value) { - _workerQueue.add { _player?.masterVolume = value } - } - - override var countInVolume: Double - get() = _player?.countInVolume ?: 0.0 - set(value) { - _workerQueue.add { _player?.countInVolume = value } - } - - override var midiEventsPlayedFilter: List - get() = _player?.midiEventsPlayedFilter ?: List() - set(value) { - _workerQueue.add { _player?.midiEventsPlayedFilter = value } - } - - override var metronomeVolume: Double - get() = _player?.metronomeVolume ?: 0.0 - set(value) { - _workerQueue.add { _player?.metronomeVolume = value } - } - - override var playbackSpeed: Double - get() = _player?.playbackSpeed ?: 0.0 - set(value) { - _workerQueue.add { _player?.playbackSpeed = value } - } - - override var tickPosition: Double - get() = _player?.tickPosition ?: 0.0 - set(value) { - _workerQueue.add { _player?.tickPosition = value } - } - - override val currentPosition: PositionChangedEventArgs - get() = _player?.currentPosition ?: PositionChangedEventArgs( - 0.0, 0.0, 0.0, 0.0, false, 120.0, 120.0 - ) - - override val loadedMidiInfo: PositionChangedEventArgs? - get() = _player?.loadedMidiInfo - - override var timePosition: Double - get() = _player?.timePosition ?: 0.0 - set(value) { - _workerQueue.add { _player?.timePosition = value } - } - - - override var playbackRange: PlaybackRange? - get() = _player?.playbackRange - set(value) { - _workerQueue.add { _player?.playbackRange = value } - } - - - override var isLooping: Boolean - get() = _player?.isLooping ?: false - set(value) { - _workerQueue.add { _player?.isLooping = value } - } - - - override fun play(): Boolean { - if (state == PlayerState.Playing || !isReadyForPlayback) { - return false - } - - _workerQueue.add { _player?.play() } - return true - } - - override fun pause() { - _workerQueue.add { _player?.pause() } - } - - override fun playOneTimeMidiFile(midi: MidiFile) { - _workerQueue.add { _player?.playOneTimeMidiFile(midi) } - } - - override fun playPause() { - _workerQueue.add { _player?.playPause() } - } - - override fun stop() { - _workerQueue.add { _player?.stop() } - } - - override fun resetSoundFonts() { - _workerQueue.add { _player?.resetSoundFonts() } - } - - override fun loadSoundFont(data: Uint8Array, append: Boolean) { - _workerQueue.add { _player?.loadSoundFont(data, append) } - } - - override fun loadMidiFile(midi: MidiFile) { - _workerQueue.add { _player?.loadMidiFile(midi) } - } - - override fun applyTranspositionPitches(transpositionPitches: alphaTab.collections.DoubleDoubleMap) { - _workerQueue.add { _player?.applyTranspositionPitches(transpositionPitches) } - } - - override fun setChannelMute(channel: Double, mute: Boolean) { - _workerQueue.add { _player?.setChannelMute(channel, mute) } - } - - override fun resetChannelStates() { - _workerQueue.add { _player?.resetChannelStates() } - } - - override fun setChannelSolo(channel: Double, solo: Boolean) { - _workerQueue.add { _player?.setChannelSolo(channel, solo) } - } - - override fun setChannelVolume(channel: Double, volume: Double) { - _workerQueue.add { _player?.setChannelVolume(channel, volume) } - } - - override fun setChannelTranspositionPitch(channel: Double, semitones: Double) { - _workerQueue.add { _player?.setChannelTranspositionPitch(channel, semitones) } - } - - override fun loadBackingTrack(score: Score) { - _workerQueue.add { _player?.loadBackingTrack(score) } - } - - override fun updateSyncPoints(syncPoints: List) { - _workerQueue.add { _player?.updateSyncPoints(syncPoints) } - } - - override val ready: IEventEmitter = EventEmitter() - override val readyForPlayback: IEventEmitter = EventEmitter() - override val finished: IEventEmitter = EventEmitter() - override val soundFontLoaded: IEventEmitter = EventEmitter() - override val soundFontLoadFailed: IEventEmitterOfT = - EventEmitterOfT() - override val midiLoaded: IEventEmitterOfT = - EventEmitterOfT() - override val midiLoadFailed: IEventEmitterOfT = - EventEmitterOfT() - override val stateChanged: IEventEmitterOfT = - EventEmitterOfT() - override val positionChanged: IEventEmitterOfT = - EventEmitterOfT() - override val midiEventsPlayed: IEventEmitterOfT = - EventEmitterOfT() - override val playbackRangeChanged: IEventEmitterOfT = - EventEmitterOfT() - - - private fun onReady() { - _uiInvoke { (ready as EventEmitter).trigger() } - } - - private fun onReadyForPlayback() { - _uiInvoke { (readyForPlayback as EventEmitter).trigger() } - } - - private fun onFinished() { - _uiInvoke { (finished as EventEmitter).trigger() } - } - - private fun onSoundFontLoaded() { - _uiInvoke { (soundFontLoaded as EventEmitter).trigger() } - } - - private fun onSoundFontLoadFailed(e: Error) { - _uiInvoke { (soundFontLoadFailed as EventEmitterOfT).trigger(e) } - } - - private fun onMidiLoaded(args: PositionChangedEventArgs) { - _uiInvoke { (midiLoaded as EventEmitterOfT).trigger(args) } - } - - private fun onMidiLoadFailed(e: Error) { - _uiInvoke { (midiLoadFailed as EventEmitterOfT).trigger(e) } - } - - private fun onMidiEventsPlayed(e: MidiEventsPlayedEventArgs) { - _uiInvoke { (midiEventsPlayed as EventEmitterOfT).trigger(e) } - } - - private fun onStateChanged(obj: PlayerStateChangedEventArgs) { - _uiInvoke { (stateChanged as EventEmitterOfT).trigger(obj) } - } - - private fun onPositionChanged(obj: PositionChangedEventArgs) { - _uiInvoke { (positionChanged as EventEmitterOfT).trigger(obj) } - } - - private fun onPlaybackRangeChanged(obj: PlaybackRangeChangedEventArgs) { - _uiInvoke { - (playbackRangeChanged as EventEmitterOfT).trigger( - obj - ) - } - } -} diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadScoreRenderer.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadScoreRenderer.kt deleted file mode 100644 index b344357da..000000000 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidThreadScoreRenderer.kt +++ /dev/null @@ -1,188 +0,0 @@ -package alphaTab.platform.android - -import alphaTab.* -import alphaTab.collections.DoubleList -import alphaTab.core.ecmaScript.Error -import alphaTab.model.Score -import alphaTab.rendering.IScoreRenderer -import alphaTab.rendering.RenderFinishedEventArgs -import alphaTab.rendering.RenderHints -import alphaTab.rendering.ScoreRenderer -import alphaTab.rendering.utils.BoundsLookup -import java.util.concurrent.BlockingQueue -import java.util.concurrent.LinkedBlockingQueue -import java.util.concurrent.Semaphore -import java.util.concurrent.TimeUnit -import kotlin.contracts.ExperimentalContracts - -@ExperimentalContracts -@ExperimentalUnsignedTypes -internal class AndroidThreadScoreRenderer : IScoreRenderer, Runnable { - private val _uiInvoke: ( action: (() -> Unit) ) -> Unit - - private val _workerThread: Thread - private val _workerQueue: BlockingQueue<() -> Unit> - private val _threadStartedEvent: Semaphore - private var _isCancelled = false - public lateinit var renderer: ScoreRenderer - private var _width: Double = 0.0 - - public constructor(settings: Settings, uiInvoke: ( action: (() -> Unit) ) -> Unit) { - _uiInvoke = uiInvoke - _threadStartedEvent = Semaphore(1) - _threadStartedEvent.acquire() - _workerQueue = LinkedBlockingQueue() - - _workerThread = Thread(this) - _workerThread.name = "alphaTabRenderThread" - _workerThread.isDaemon = true - _workerThread.start() - - _threadStartedEvent.acquire() - - _workerQueue.add { initialize(settings) } - } - - override fun run() { - _threadStartedEvent.release() - try { - do { - val item = _workerQueue.poll(500, TimeUnit.MILLISECONDS) - if (!_isCancelled && item != null) { - item() - } - } while (!_isCancelled) - } catch (e: InterruptedException) { - // finished - } - } - - private fun initialize(settings: Settings) { - renderer = ScoreRenderer(settings) - renderer.partialRenderFinished.on { - _uiInvoke { onPartialRenderFinished(it) } - } - renderer.partialLayoutFinished.on { - _uiInvoke { onPartialLayoutFinished(it) } - } - renderer.renderFinished.on { - _uiInvoke { onRenderFinished(it) } - } - renderer.postRenderFinished.on { - _uiInvoke { onPostFinished(renderer.boundsLookup) } - } - renderer.preRender.on { - _uiInvoke { onPreRender(it) } - } - renderer.error.on { - _uiInvoke { onError(it) } - } - } - - private fun onPostFinished(boundsLookup: BoundsLookup?) { - this.boundsLookup = boundsLookup - onPostRenderFinished() - } - - override var boundsLookup: BoundsLookup? = null - override var width: Double - get() = _width - set(value) { - _width = value - if (checkAccess()) { - renderer.width = value - } else { - _workerQueue.add { renderer.width = value } - } - } - - override fun render(renderHints: RenderHints?) { - if (checkAccess()) { - renderer.render(renderHints) - } else { - _workerQueue.add { render(renderHints) } - } - } - - override fun renderResult(resultId:String) { - if (checkAccess()) { - renderer.renderResult(resultId) - } else { - _workerQueue.add { renderResult(resultId) } - } - } - - override fun resizeRender() { - if (checkAccess()) { - renderer.resizeRender() - } else { - _workerQueue.add { resizeRender() } - } - } - - override fun renderScore(score: Score?, trackIndexes: DoubleList?, renderHints: RenderHints?) { - if (checkAccess()) { - renderer.renderScore(score, trackIndexes, renderHints) - } else { - _workerQueue.add { - renderScore( - score, - trackIndexes, - renderHints - ) - } - } - } - - override fun updateSettings(settings: Settings) { - if (checkAccess()) { - renderer.updateSettings(settings) - } else { - _workerQueue.add { updateSettings(settings) } - } - } - - private fun checkAccess(): Boolean { - return Thread.currentThread().id == _workerThread.id - } - - override fun destroy() { - _isCancelled = true - _workerThread.interrupt() - _workerThread.join() - } - - override val preRender: IEventEmitterOfT = EventEmitterOfT() - private fun onPreRender(isResize: Boolean) { - (preRender as EventEmitterOfT).trigger(isResize) - } - - override val renderFinished: IEventEmitterOfT = EventEmitterOfT() - private fun onRenderFinished(args: RenderFinishedEventArgs) { - (renderFinished as EventEmitterOfT).trigger(args) - } - - override val partialRenderFinished: IEventEmitterOfT = - EventEmitterOfT() - - private fun onPartialRenderFinished(args: RenderFinishedEventArgs) { - (partialRenderFinished as EventEmitterOfT).trigger(args) - } - - override val partialLayoutFinished: IEventEmitterOfT = - EventEmitterOfT() - - private fun onPartialLayoutFinished(args: RenderFinishedEventArgs) { - (partialLayoutFinished as EventEmitterOfT).trigger(args) - } - - override val postRenderFinished: IEventEmitter = EventEmitter() - private fun onPostRenderFinished() { - (postRenderFinished as EventEmitter).trigger() - } - - override val error: IEventEmitterOfT = EventEmitterOfT() - private fun onError(e: Error) { - (error as EventEmitterOfT).trigger(e) - } -} diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidUiFacade.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidUiFacade.kt index 479b980fc..5d4be7bcf 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidUiFacade.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidUiFacade.kt @@ -14,6 +14,9 @@ import alphaTab.platform.IContainer import alphaTab.platform.IMouseEventArgs import alphaTab.platform.IUiFacade import alphaTab.platform.skia.AlphaSkiaImage +import alphaTab.platform.worker.AlphaSynthAudioExporterWorkerApi +import alphaTab.platform.worker.AlphaSynthWebWorkerApi +import alphaTab.platform.worker.AlphaTabWorkerScoreRenderer import alphaTab.rendering.IScoreRenderer import alphaTab.rendering.RenderFinishedEventArgs import alphaTab.rendering.utils.Bounds @@ -29,6 +32,12 @@ import android.view.ViewGroup import android.view.ViewTreeObserver import android.widget.RelativeLayout import androidx.core.view.children +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.ByteArrayOutputStream import java.io.InputStream import java.nio.ByteBuffer @@ -39,7 +48,6 @@ import kotlin.contracts.ExperimentalContracts @ExperimentalUnsignedTypes @SuppressLint("ClickableViewAccessibility") internal class AndroidUiFacade : IUiFacade { - private var _handler: Handler private var _internalRootContainerBecameVisible: EventEmitter? = EventEmitter() private val _outerScroll: SuspendableHorizontalScrollView private val _innerScroll: SuspendableScrollView @@ -59,7 +67,6 @@ internal class AndroidUiFacade : IUiFacade { rootContainer = AndroidRootViewContainer(outerScroll, innerScroll, renderSurface, this::beginInvoke) - _handler = Handler(outerScroll.context.mainLooper) rootContainerBecameVisible = object : IEventEmitter, ViewTreeObserver.OnGlobalLayoutListener, View.OnLayoutChangeListener { @@ -151,7 +158,12 @@ internal class AndroidUiFacade : IUiFacade { } override fun createWorkerRenderer(): IScoreRenderer { - return AndroidThreadScoreRenderer(api.settings, this::beginInvoke) + val worker = JavaThreadAlphaTabRendererWorker(this::postToUIThread) + return AlphaTabWorkerScoreRenderer(api, worker) + } + + private fun postToUIThread(action: () -> Unit) { + this._renderSurface.post(action) } private fun openDefaultSoundFont(): InputStream { @@ -159,15 +171,12 @@ internal class AndroidUiFacade : IUiFacade { } override fun createWorkerPlayer(): IAlphaSynth { - var player: AndroidThreadAlphaSynthWorkerPlayer? = null - player = AndroidThreadAlphaSynthWorkerPlayer( - api.settings.core.logLevel, - AndroidSynthOutput(_renderWrapper.context) { - player!!.addToWorker(it) - }, - this::beginInvoke, - api.settings.player.bufferTimeInMilliseconds + val player = AlphaSynthWebWorkerApi( + AndroidSynthOutput(_renderWrapper.context), + api.settings, + JavaThreadAlphaSynthWorker(this::postToUIThread) ) + player.ready.on { val soundFont = openDefaultSoundFont() val bos = ByteArrayOutputStream() @@ -180,16 +189,16 @@ internal class AndroidUiFacade : IUiFacade { } override fun createWorkerAudioExporter(synth: IAlphaSynth?): IAudioExporterWorker { - val needNewWorker = synth == null || synth !is AndroidThreadAlphaSynthWorkerPlayer + val needNewWorker = synth == null || synth !is AlphaSynthWebWorkerApi var synthToUse = synth if (needNewWorker) { - synthToUse = this.createWorkerPlayer(); + synthToUse = this.createWorkerPlayer() } - return AndroidThreadAlphaSynthAudioExporter( - synthToUse as AndroidThreadAlphaSynthWorkerPlayer, + return AlphaSynthAudioExporterWorkerApi( + synthToUse as AlphaSynthWebWorkerApi, needNewWorker - ); + ) } @@ -232,7 +241,7 @@ internal class AndroidUiFacade : IUiFacade { } override fun beginAppendRenderResults(renderResults: RenderFinishedEventArgs?) { - _handler.post { + postToUIThread { if (renderResults != null) { _renderSurface.addPlaceholder(renderResults) } @@ -240,7 +249,7 @@ internal class AndroidUiFacade : IUiFacade { } override fun beginUpdateRenderResults(renderResults: RenderFinishedEventArgs) { - _handler.post { + postToUIThread { // convert AlphaSkia image to Android Bitmap val renderResult = renderResults.renderResult if (renderResult is AlphaSkiaImage) { @@ -345,12 +354,42 @@ internal class AndroidUiFacade : IUiFacade { } override fun beginInvoke(action: () -> Unit) { - _handler.post(action) + // post to "own" event loop if running inside worker + val synthWorker = JavaThreadAlphaSynthWorker.currentThreadWorker + if (synthWorker != null) { + synthWorker.postToWorker(action) + return + } + + val renderWorker = JavaThreadAlphaTabRendererWorker.currentThreadWorker + if (renderWorker != null) { + renderWorker.postToWorker(action) + return + } + + // not in worker -> run on main + postToUIThread(action) } override fun removeHighlights() { } + // The main throttle scope is not on Main but we then trigger the actual logic via postToUIThread. + // this way we do not load the UI thread unnecessarily until we have actual work. + private val throttleScope = CoroutineScope(Dispatchers.Default) + override fun throttle(action: () -> Unit, delay: Double): () -> Unit { + // we schedule delayed jobs + var job: Job? = null + return { + job?.cancel() + @Suppress("AssignedValueIsNeverRead") + job = throttleScope.launch { + delay(delay.toLong()) + postToUIThread(action) + } + } + } + override fun createSelectionElement(): IContainer? { val selection = object : View(_renderWrapper.context) { override fun onTouchEvent(event: MotionEvent?): Boolean { @@ -403,7 +442,7 @@ internal class AndroidUiFacade : IUiFacade { overflow: Double, isVertical: Boolean ) { - val view = (canvasElement as AndroidViewContainer).view; + val view = (canvasElement as AndroidViewContainer).view if (view is AlphaTabRenderSurface) { if (isVertical) { view.setPadding(0, 0, 0, overflow.toInt()) diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt new file mode 100644 index 000000000..8d5dc4b0e --- /dev/null +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt @@ -0,0 +1,150 @@ +package alphaTab.platform.android + +import alphaTab.core.ecmaScript.MessageEvent +import alphaTab.platform.worker.AlphaSynthWebWorker +import alphaTab.platform.worker.IAlphaSynthWorker +import alphaTab.platform.worker.IAlphaSynthWorkerMessage +import alphaTab.platform.worker.IAlphaTabRenderingWorker +import alphaTab.platform.worker.IAlphaTabWorker +import alphaTab.platform.worker.IAlphaTabWorkerGlobalScope +import alphaTab.platform.worker.IAlphaTabWorkerMessage +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.Semaphore +import kotlin.contracts.ExperimentalContracts + +@OptIn(ExperimentalContracts::class, ExperimentalUnsignedTypes::class) +internal abstract class JavaThreadWorkerBase : IAlphaTabWorker, Runnable { + private val _postToMain: (action: () -> Unit) -> Unit + private val _workerThread: Thread + private val _workerQueue = LinkedBlockingQueue<() -> Unit>() + private var _isCancelled = false + private val _threadStartedEvent = Semaphore(1) + private val _listenerInsideWorker = ArrayList<(ev: MessageEvent) -> Unit>() + private val _listenerOutsideWorker = ArrayList<(ev: MessageEvent) -> Unit>() + + protected constructor(postToMain: (action: () -> Unit) -> Unit) { + _postToMain = postToMain; + + _workerThread = Thread(this) + _workerThread.isDaemon = true + _workerThread.start() + + _threadStartedEvent.acquire() + } + + protected abstract fun onStartInsideWorker() + + override fun run() { + _threadStartedEvent.release(); + onStartInsideWorker(); + while (!_isCancelled) { + try { + val item = _workerQueue.take(); + if (!_isCancelled && item != null) { + item() + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + break; + } + } + } + + override fun postMessage(message: T) { + val ev = MessageEvent(message); + if (Thread.currentThread().id == _workerThread.id) { + // Inside Worker -> Post to main + _postToMain( + { + for (listener in _listenerOutsideWorker) { + listener(ev); + } + }); + } else { + // Outside Worker -> Post to worker + postToWorker( + { + for (listener in _listenerInsideWorker) { + listener(ev); + } + }); + } + } + + public fun postToWorker(action: () -> Unit) { + _workerQueue.add(action); + } + + public override fun addEventListener(event: String, handler: (ev: MessageEvent) -> Unit) { + if (event != "message") { + return; + } + val listeners = if (Thread.currentThread().id == _workerThread.id) { + _listenerInsideWorker + } else { + _listenerOutsideWorker + }; + listeners.add(handler); + } + + override fun removeEventListener(event: String, handler: (arg1: MessageEvent) -> Unit) { + if (event != "message") { + return; + } + val listeners = if (Thread.currentThread().id == _workerThread.id) { + _listenerInsideWorker + } else { + _listenerOutsideWorker + }; + listeners.remove(handler); + } + + override fun terminate() { + _isCancelled = true + _workerThread.interrupt() + _workerThread.join() + } +} + +@OptIn(ExperimentalContracts::class, ExperimentalUnsignedTypes::class) +internal class JavaThreadAlphaTabRendererWorker(postToMain: (action: () -> Unit) -> Unit) : + JavaThreadWorkerBase(postToMain), + IAlphaTabRenderingWorker, + IAlphaTabWorkerGlobalScope { + companion object { + private val workerLookup = ConcurrentHashMap() + + val currentThreadWorker: JavaThreadAlphaTabRendererWorker? + get() { + return workerLookup.getOrDefault(Thread.currentThread().id, null) + } + } + + @OptIn(ExperimentalContracts::class, ExperimentalUnsignedTypes::class) + override fun onStartInsideWorker() { + workerLookup[Thread.currentThread().id] = this; + AlphaSynthWebWorker.init(); + } +} + +@OptIn(ExperimentalContracts::class, ExperimentalUnsignedTypes::class) +internal class JavaThreadAlphaSynthWorker(postToMain: (action: () -> Unit) -> Unit) : + JavaThreadWorkerBase(postToMain), + IAlphaSynthWorker, + IAlphaTabWorkerGlobalScope { + companion object { + private val workerLookup = ConcurrentHashMap() + + val currentThreadWorker: JavaThreadAlphaSynthWorker? + get() { + return workerLookup.getOrDefault(Thread.currentThread().id, null) + } + } + + @OptIn(ExperimentalContracts::class, ExperimentalUnsignedTypes::class) + override fun onStartInsideWorker() { + workerLookup[Thread.currentThread().id] = this; + AlphaSynthWebWorker.init(); + } +} diff --git a/packages/transpiler/src/AstPrinterBase.ts b/packages/transpiler/src/AstPrinterBase.ts index 34764cd0e..af8ddfc68 100644 --- a/packages/transpiler/src/AstPrinterBase.ts +++ b/packages/transpiler/src/AstPrinterBase.ts @@ -256,7 +256,7 @@ export default abstract class AstPrinterBase { if (expr.nullSafe) { this.write('?.'); - this.write(this._context.toMethodName('invoke')); + this.write(this._context.toMethodNameCase('invoke')); } this.write('('); diff --git a/packages/transpiler/src/csharp/CSharpAstTransformer.ts b/packages/transpiler/src/csharp/CSharpAstTransformer.ts index 3e835091e..c98b6a68d 100644 --- a/packages/transpiler/src/csharp/CSharpAstTransformer.ts +++ b/packages/transpiler/src/csharp/CSharpAstTransformer.ts @@ -44,7 +44,7 @@ export default class CSharpAstTransformer { namespace: { parent: null, nodeType: cs.SyntaxKind.NamespaceDeclaration, - namespace: this.context.toPascalCase('alphaTab'), + namespace: this.context.toNamespaceNameCase('alphaTab'), declarations: [] } }; @@ -281,7 +281,8 @@ export default class CSharpAstTransformer { folders.shift(); } this.csharpFile.namespace.namespace = - this.context.toPascalCase('alphaTab') + folders.map(f => `.${this.context.toPascalCase(f)}`).join(''); + this.context.toNamespaceNameCase('alphaTab') + + folders.map(f => `.${this.context.toNamespaceNameCase(f)}`).join(''); if (defaultExport) { this.visit( @@ -523,48 +524,7 @@ export default class CSharpAstTransformer { } // Create base interface - const baseInterface: cs.InterfaceDeclaration = { - visibility: this.getVisibility(node), - name: node.name.text, - nodeType: cs.SyntaxKind.InterfaceDeclaration, - parent: this.csharpFile.namespace, - members: [], - tsNode: node, - skipEmit: this.shouldSkip(node, false), - partial: false, - tsSymbol: this.context.getSymbolForDeclaration(node), - hasVirtualMembersOrSubClasses: false - }; - - if (node.name) { - baseInterface.documentation = this.visitDocumentation(node.name); - } - - this._visitDocumentationAttributes(baseInterface, node); - - // Add discriminator property to interface - const discriminatorProperty: cs.PropertyDeclaration = { - visibility: cs.Visibility.Public, - name: this.context.toPropertyName(discriminatorField), - nodeType: cs.SyntaxKind.PropertyDeclaration, - parent: baseInterface, - isVirtual: false, - isOverride: false, - isAbstract: false, - isStatic: false, - type: this.createUnresolvedTypeNode(null, node.type, this.context.typeChecker.getStringType()), - tsNode: node, - getAccessor: { - keyword: 'get' - } as cs.PropertyAccessorDeclaration, - setAccessor: { - keyword: 'set' - } as cs.PropertyAccessorDeclaration, - skipEmit: false - }; - - baseInterface.members.push(discriminatorProperty); - + const baseInterface = this.createDiscriminatedUnionBaseInterface(node, discriminatorField); this.csharpFile.namespace.declarations.push(baseInterface); this.context.registerSymbol(baseInterface); @@ -595,95 +555,158 @@ export default class CSharpAstTransformer { typeNamePrefix + suffix .split('.') - .map(p => this.context.toPascalCase(p)) + .map(p => this.context.toTypeNameCase(p)) .join(''); - // Create class - const csClass: cs.ClassDeclaration = { - visibility: this.getVisibility(node), - name: className, - nodeType: cs.SyntaxKind.ClassDeclaration, - parent: this.csharpFile.namespace, - isAbstract: false, - members: [], - skipEmit: this.shouldSkip(node, false), - partial: false, - tsNode: memberType.symbol.declarations![0], - tsSymbol: memberType.symbol, - hasVirtualMembersOrSubClasses: false, - isRecord: true - }; + const csClass = this.createDiscriminatedUnionClass( + node, + className, + memberType, + baseInterface, + discriminatorField, + discriminatorValue + ); + this.csharpFile.namespace.declarations.push(csClass); + this.context.registerSymbol(csClass); + } + } + protected createDiscriminatedUnionClass( + node: ts.TypeAliasDeclaration, + className: string, + memberType: ts.Type, + baseInterface: cs.InterfaceDeclaration, + discriminatorField: string, + discriminatorValue: string + ) { + // Create class + const csClass: cs.ClassDeclaration = { + visibility: this.getVisibility(node), + name: className, + nodeType: cs.SyntaxKind.ClassDeclaration, + parent: this.csharpFile.namespace, + isAbstract: false, + members: [], + skipEmit: this.shouldSkip(node, false), + partial: false, + tsNode: memberType.symbol.declarations![0], + tsSymbol: memberType.symbol, + hasVirtualMembersOrSubClasses: false, + isRecord: true + }; - // Add interface implementation - csClass.interfaces = [ - { - nodeType: cs.SyntaxKind.TypeReference, - parent: csClass, - reference: this.context.makeTypeName(baseInterface.name), - isAsync: false - } as cs.TypeReference - ]; + // Add interface implementation + csClass.interfaces = [ + { + nodeType: cs.SyntaxKind.TypeReference, + parent: csClass, + reference: this.context.makeTypeName(baseInterface.name), + isAsync: false + } as cs.TypeReference + ]; - // Add discriminator property - const discProp: cs.PropertyDeclaration = { + // Add discriminator property + const discProp: cs.PropertyDeclaration = { + visibility: cs.Visibility.Public, + name: this.context.toPropertyNameCase(discriminatorField), + nodeType: cs.SyntaxKind.PropertyDeclaration, + parent: csClass, + isVirtual: false, + isOverride: false, + isAbstract: false, + isStatic: false, + type: this.createUnresolvedTypeNode(null, node.type, this.context.typeChecker.getStringType()), + initializer: { + nodeType: cs.SyntaxKind.StringLiteral, + text: discriminatorValue + } as cs.StringLiteral, + getAccessor: { + keyword: 'get' + } as cs.PropertyAccessorDeclaration, + setAccessor: { + keyword: 'set' + } as cs.PropertyAccessorDeclaration, + tsNode: node, + skipEmit: false + }; + discProp.initializer!.parent = discProp; + + csClass.members.push(discProp); + + // Add other properties + const properties = this.context.typeChecker.getPropertiesOfType(memberType); + const otherProperties = properties.filter(p => p.name !== discriminatorField); + for (const prop of otherProperties) { + const propType = this.context.typeChecker.getTypeOfSymbolAtLocation(prop, node); + + // Create property + const csProperty: cs.PropertyDeclaration = { visibility: cs.Visibility.Public, - name: this.context.toPropertyName(discriminatorField), + name: this.context.toPropertyNameCase(prop.name), nodeType: cs.SyntaxKind.PropertyDeclaration, parent: csClass, isVirtual: false, isOverride: false, isAbstract: false, isStatic: false, - type: this.createUnresolvedTypeNode(null, node.type, this.context.typeChecker.getStringType()), - initializer: { - nodeType: cs.SyntaxKind.StringLiteral, - text: discriminatorValue - } as cs.StringLiteral, + type: this.createUnresolvedTypeNode(null, node.type, propType), + tsNode: prop.valueDeclaration ?? prop.declarations![0], getAccessor: { keyword: 'get' } as cs.PropertyAccessorDeclaration, setAccessor: { keyword: 'set' } as cs.PropertyAccessorDeclaration, - tsNode: node, skipEmit: false }; - discProp.initializer!.parent = discProp; - csClass.members.push(discProp); + csClass.members.push(csProperty); + } + return csClass; + } - // Add other properties - const otherProperties = properties.filter(p => p.name !== discriminatorField); - for (const prop of otherProperties) { - const propType = this.context.typeChecker.getTypeOfSymbolAtLocation(prop, node); + protected createDiscriminatedUnionBaseInterface(node: ts.TypeAliasDeclaration, discriminatorField: string) { + const baseInterface: cs.InterfaceDeclaration = { + visibility: this.getVisibility(node), + name: node.name.text, + nodeType: cs.SyntaxKind.InterfaceDeclaration, + parent: this.csharpFile.namespace, + members: [], + tsNode: node, + skipEmit: this.shouldSkip(node, false), + partial: false, + tsSymbol: this.context.getSymbolForDeclaration(node), + hasVirtualMembersOrSubClasses: false + }; - // Create property - const csProperty: cs.PropertyDeclaration = { - visibility: cs.Visibility.Public, - name: this.context.toPropertyName(prop.name), - nodeType: cs.SyntaxKind.PropertyDeclaration, - parent: csClass, - isVirtual: false, - isOverride: false, - isAbstract: false, - isStatic: false, - type: this.createUnresolvedTypeNode(null, node.type, propType), - tsNode: node, - getAccessor: { - keyword: 'get' - } as cs.PropertyAccessorDeclaration, - setAccessor: { - keyword: 'set' - } as cs.PropertyAccessorDeclaration, - skipEmit: false - }; + if (node.name) { + baseInterface.documentation = this.visitDocumentation(node.name); + } - csClass.members.push(csProperty); - } + this._visitDocumentationAttributes(baseInterface, node); - this.csharpFile.namespace.declarations.push(csClass); - this.context.registerSymbol(csClass); - } + // Add discriminator property to interface + const discriminatorProperty: cs.PropertyDeclaration = { + visibility: cs.Visibility.Public, + name: this.context.toPropertyNameCase(discriminatorField), + nodeType: cs.SyntaxKind.PropertyDeclaration, + parent: baseInterface, + isVirtual: false, + isOverride: false, + isAbstract: false, + isStatic: false, + type: this.createUnresolvedTypeNode(null, node.type, this.context.typeChecker.getStringType()), + tsNode: node, + getAccessor: { + keyword: 'get' + } as cs.PropertyAccessorDeclaration, + setAccessor: { + keyword: 'set' + } as cs.PropertyAccessorDeclaration, + skipEmit: false + }; + + baseInterface.members.push(discriminatorProperty); + return baseInterface; } protected visitInterfaceDeclaration(node: ts.InterfaceDeclaration) { @@ -885,7 +908,7 @@ export default class CSharpAstTransformer { isOverride: false, isStatic: false, isVirtual: false, - name: this.context.toPascalCase(m.name.getText()), + name: this.context.toPropertyNameCase(m.name.getText()), type: this.createUnresolvedTypeNode(null, m.type ?? m, type), visibility: cs.Visibility.Public, tsNode: m, @@ -1015,7 +1038,7 @@ export default class CSharpAstTransformer { nodeType: cs.SyntaxKind.ThisLiteral, parent: stmt.expression } as cs.ThisLiteral, - member: this.context.toPascalCase(p.name.getText()) + member: this.context.toPropertyNameCase(p.name.getText()) } as cs.MemberAccessExpression; ((stmt.expression as cs.BinaryExpression).left as cs.MemberAccessExpression).expression.parent = ( @@ -1104,7 +1127,7 @@ export default class CSharpAstTransformer { const testClassName = (d.arguments[0] as ts.StringLiteral).text; const csClass: cs.ClassDeclaration = { visibility: cs.Visibility.Public, - name: this.context.toPascalCase(this.context.toIdentifier(testClassName)), + name: this.context.toTypeNameCase(this.context.toIdentifier(testClassName)), tsNode: d, nodeType: cs.SyntaxKind.ClassDeclaration, parent: this.csharpFile.namespace, @@ -1193,7 +1216,7 @@ export default class CSharpAstTransformer { isVirtual: false, isGeneratorFunction: false, partial: !!ts.getJSDocTags(d).find(t => t.tagName.text === 'partial'), - name: this.context.toPascalCase((d.name as ts.Identifier).text), + name: this.context.toMethodNameCase((d.name as ts.Identifier).text), parameters: [], returnType: this.createUnresolvedTypeNode(null, d.type ?? d, returnType), visibility: this.mapVisibility(d, cs.Visibility.Private), @@ -1231,7 +1254,7 @@ export default class CSharpAstTransformer { } } - name = this.context.toMethodName(name); + name = this.context.toMethodNameCase(name); const csMethod: cs.MethodDeclaration = { parent: parent, nodeType: cs.SyntaxKind.MethodDeclaration, @@ -1354,7 +1377,7 @@ export default class CSharpAstTransformer { isTestMethod: false, isGeneratorFunction: false, partial: !!ts.getJSDocTags(d).find(t => t.tagName.text === 'partial'), - name: this.context.toPascalCase(d.name.getText()), + name: this.context.toMethodNameCase(d.name.getText()), returnType: {} as cs.TypeNode, visibility: cs.Visibility.Private, tsNode: d, @@ -1398,7 +1421,7 @@ export default class CSharpAstTransformer { isOverride: false, isStatic: true, isVirtual: false, - name: this.context.toPascalCase(d.name.getText()), + name: this.context.toPropertyNameCase(d.name.getText()), type: this.createUnresolvedTypeNode(null, d.type ?? d, type), visibility: cs.Visibility.Private, tsNode: d @@ -1727,7 +1750,7 @@ export default class CSharpAstTransformer { isOverride: false, isStatic: false, isVirtual: false, - name: this.context.toPascalCase((classElement.name as ts.Identifier).text), + name: this.context.toPropertyNameCase((classElement.name as ts.Identifier).text), type: this.createUnresolvedTypeNode(null, classElement.type ?? classElement, type), visibility: cs.Visibility.None, tsNode: classElement, @@ -1770,7 +1793,7 @@ export default class CSharpAstTransformer { } protected visitGetAccessor(parent: cs.ClassDeclaration, classElement: ts.GetAccessorDeclaration) { - const propertyName = this.context.toPascalCase(classElement.name.getText()); + const propertyName = this.context.toPropertyNameCase(classElement.name.getText()); const member = parent.members.find(m => m.name === propertyName); if (member && cs.isPropertyDeclaration(member)) { member.getAccessor = { @@ -1836,7 +1859,7 @@ export default class CSharpAstTransformer { } protected visitSetAccessor(parent: cs.ClassDeclaration, classElement: ts.SetAccessorDeclaration) { - const propertyName = this.context.toPascalCase(classElement.name.getText()); + const propertyName = this.context.toPropertyNameCase(classElement.name.getText()); const member = parent.members.find(m => m.name === propertyName); if (member && cs.isPropertyDeclaration(member)) { member.setAccessor = { @@ -1990,7 +2013,7 @@ export default class CSharpAstTransformer { isOverride: false, isStatic: false, isVirtual: false, - name: this.context.toPropertyName(classElement.name.getText()), + name: this.context.toPropertyNameCase(classElement.name.getText()), type: this.createUnresolvedTypeNode(null, classElement.type ?? classElement, type), visibility: visibility, tsNode: classElement, @@ -2145,13 +2168,13 @@ export default class CSharpAstTransformer { } switch (csMethod.name) { - case this.context.toMethodName('toString'): + case this.context.toMethodNameCase('toString'): if (csMethod.parameters.length === 0) { csMethod.isVirtual = false; csMethod.isOverride = true; } break; - case this.context.toMethodName('equals'): + case this.context.toMethodNameCase('equals'): if (csMethod.parameters.length === 1) { csMethod.isVirtual = false; csMethod.isOverride = true; @@ -3045,7 +3068,7 @@ export default class CSharpAstTransformer { parent: parent, expression: null!, tsSymbol: enumMember.symbol, - member: this.context.toPropertyName(enumMember.symbol.name) + member: this.context.toPropertyNameCase(enumMember.symbol.name) } as cs.MemberAccessExpression; const identifier = { @@ -3161,7 +3184,7 @@ export default class CSharpAstTransformer { csExpr.expression = this.makeMemberAccess( csExpr, this.context.makeTypeName('alphaTab.core.TypeHelper'), - this.context.toMethodName('typeOf') + this.context.toMethodNameCase('typeOf') ); const e = this.visitExpression(csExpr, expression.expression); if (e) { @@ -3218,7 +3241,7 @@ export default class CSharpAstTransformer { csExpr.expression = this.makeMemberAccess( csExpr, this.context.makeTypeName('alphaTab.core.TypeHelper'), - this.context.toMethodName('in') + this.context.toMethodNameCase('in') ); let e = this.visitExpression(csExpr, expression.left)!; @@ -3690,7 +3713,7 @@ export default class CSharpAstTransformer { tsNode: expression.tsNode, nodeType: cs.SyntaxKind.MemberAccessExpression, expression: {} as cs.Expression, - member: this.context.toMethodName('isTruthy') + member: this.context.toMethodNameCase('isTruthy') } as cs.MemberAccessExpression; call.expression = access; @@ -3865,7 +3888,7 @@ export default class CSharpAstTransformer { csExpr.expression = this.makeMemberAccess( csExpr, this.context.makeTypeName('alphaTab.core.TypeHelper'), - this.context.toMethodName('createRegex') + this.context.toMethodNameCase('createRegex') ); csExpr.arguments.push({ parent: csExpr, @@ -4163,7 +4186,7 @@ export default class CSharpAstTransformer { const memberAccess = { expression: {} as cs.Expression, - member: this.context.toPropertyName(expression.name.text), + member: this.context.toPropertyNameCase(expression.name.text), parent: parent, tsNode: expression, tsSymbol: tsSymbol, @@ -4176,7 +4199,9 @@ export default class CSharpAstTransformer { if (this.context.isMethodSymbol(memberAccess.tsSymbol)) { memberAccess.member = this.context.buildMethodName(expression.name); } else if (this.context.isPropertySymbol(memberAccess.tsSymbol)) { - memberAccess.member = this.context.toPropertyName(expression.name.text); + memberAccess.member = this.context.toPropertyNameCase(expression.name.text); + } else if (memberAccess.tsSymbol.flags & ts.SymbolFlags.EnumMember) { + memberAccess.member = expression.name.text; } } @@ -4540,17 +4565,28 @@ export default class CSharpAstTransformer { p => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === discriminatorField ) as ts.PropertyAssignment; - const discriminatorValue = ts.isStringLiteral(discriminatorProp!.initializer) ? discriminatorProp.initializer.text : undefined; + const discriminatorValue = ts.isStringLiteral(discriminatorProp!.initializer) + ? discriminatorProp.initializer.text + : undefined; const matching = unionType.types.find(memberType => { const prop = memberType.getProperty(discriminatorField); - if (!prop) {return false;} + if (!prop) { + return false; + } const propType = this.context.typeChecker.getTypeOfSymbolAtLocation(prop, expression); - return propType.flags & ts.TypeFlags.StringLiteral && (propType as ts.StringLiteralType).value === discriminatorValue; + return ( + propType.flags & ts.TypeFlags.StringLiteral && + (propType as ts.StringLiteralType).value === discriminatorValue + ); }); - if(!matching) { - this.context.addCsNodeDiagnostics(parent, "Could not resolve concrete union type", ts.DiagnosticCategory.Error); + if (!matching) { + this.context.addCsNodeDiagnostics( + parent, + 'Could not resolve concrete union type', + ts.DiagnosticCategory.Error + ); return null; } @@ -4571,11 +4607,11 @@ export default class CSharpAstTransformer { } as cs.LabeledExpression; if (ts.isPropertyAssignment(p)) { - assignment.label = this.context.toPropertyName(p.name.getText()); + assignment.label = this.context.toPropertyNameCase(p.name.getText()); assignment.expression = this.visitExpression(assignment, p.initializer)!; newObject.objectInitializers!.push(assignment); } else if (ts.isShorthandPropertyAssignment(p)) { - assignment.label = this.context.toPropertyName(p.name.getText()); + assignment.label = this.context.toPropertyNameCase(p.name.getText()); if (p.objectAssignmentInitializer) { assignment.expression = this.visitExpression(assignment, p.objectAssignmentInitializer)!; } else { @@ -4623,7 +4659,7 @@ export default class CSharpAstTransformer { } as cs.InvocationExpression; const memberAccess = { expression: null!, - member: this.context.toPascalCase('toInvariantString'), + member: this.context.toMethodNameCase('toInvariantString'), parent: callExpr, tsNode: expr.tsNode, nodeType: cs.SyntaxKind.MemberAccessExpression @@ -4660,7 +4696,7 @@ export default class CSharpAstTransformer { const memberAccess = { expression: {} as cs.Expression, - member: this.context.toPascalCase('toString'), + member: this.context.toMethodNameCase('toString'), parent: callExpr, tsNode: expression, nodeType: cs.SyntaxKind.MemberAccessExpression @@ -4686,7 +4722,7 @@ export default class CSharpAstTransformer { callExpr.expression = this.makeMemberAccess( callExpr, this.context.makeTypeName('alphaTab.core.TypeHelper'), - this.context.toMethodName('parseEnum') + this.context.toMethodNameCase('parseEnum') ); const enumType = this.context.typeChecker.getTypeAtLocation(expression.expression); @@ -4716,7 +4752,7 @@ export default class CSharpAstTransformer { const memberAccess = { nodeType: cs.SyntaxKind.MemberAccessExpression, expression: {} as cs.Expression, - member: this.context.toMethodName(elementAccessMethod), + member: this.context.toMethodNameCase(elementAccessMethod), parent: parent, tsNode: expression, nullSafe: !!expression.questionDotToken @@ -4792,7 +4828,7 @@ export default class CSharpAstTransformer { if (ts.isNumericLiteral(index)) { return { expression: elementAccess.expression, - member: this.context.toPropertyName(`v${index.text}`), + member: this.context.toPropertyNameCase(`v${index.text}`), parent: parent, tsNode: expression, nodeType: cs.SyntaxKind.MemberAccessExpression, @@ -4906,7 +4942,7 @@ export default class CSharpAstTransformer { parent: callExpression, tsNode: expression.expression, nodeType: cs.SyntaxKind.Identifier, - text: `TestGlobals.${this.context.toPascalCase('expect')}` + text: `TestGlobals.${this.context.toMethodNameCase('expect')}` } as cs.Identifier; } else { callExpression.expression = this.visitExpression(callExpression, expression.expression)!; @@ -4965,7 +5001,7 @@ export default class CSharpAstTransformer { invocation.expression = this.makeMemberAccess( invocation, this.context.makeTypeName('alphaTab.core.TypeHelper'), - this.context.toMethodName('createPromise') + this.context.toMethodNameCase('createPromise') ); const e = this.visitExpression(invocation, expression.arguments![0]); @@ -5079,7 +5115,7 @@ export default class CSharpAstTransformer { csExpr.expression = this.makeMemberAccess( csExpr, this.context.makeTypeName('alphaTab.core.TypeHelper'), - this.context.toMethodName('unknownToNumber') + this.context.toMethodNameCase('unknownToNumber') ); const e = this.visitExpression(csExpr, expression.expression); if (e) { @@ -5178,6 +5214,14 @@ export default class CSharpAstTransformer { identifier.text = this.getIdentifierName(identifier, expression); if (identifier.tsSymbol) { + if (identifier.tsSymbol) { + if (this.context.isMethodSymbol(identifier.tsSymbol)) { + identifier.text = this.context.toMethodNameCase(identifier.text); + } else if (this.context.isPropertySymbol(identifier.tsSymbol)) { + identifier.text = this.context.toPropertyNameCase(identifier.text); + } + } + switch (expression.parent.kind) { case ts.SyntaxKind.PropertyAccessExpression: case ts.SyntaxKind.BinaryExpression: diff --git a/packages/transpiler/src/csharp/CSharpEmitterContext.ts b/packages/transpiler/src/csharp/CSharpEmitterContext.ts index 37f94d449..03e71f7d8 100644 --- a/packages/transpiler/src/csharp/CSharpEmitterContext.ts +++ b/packages/transpiler/src/csharp/CSharpEmitterContext.ts @@ -14,7 +14,6 @@ export default class CSharpEmitterContext { private _unresolvedTypeNodes: cs.UnresolvedTypeNode[] = []; private _program: ts.Program; public typeChecker: ts.TypeChecker; - public noPascalCase: boolean = false; public csharpFiles: cs.SourceFile[] = []; public processingSkippedElement: boolean = false; @@ -30,14 +29,22 @@ export default class CSharpEmitterContext { return this.typeChecker.getTypeAtLocation(n); } - public toMethodName(text: string): string { + public toMethodNameCase(text: string): string { return this.toPascalCase(this.toIdentifier(text)); } - public toPropertyName(text: string): string { + public toPropertyNameCase(text: string): string { return this.toPascalCase(this.toIdentifier(text)); } + public toNamespaceNameCase(text: string): string { + return this.toPascalCase(text); + } + + public toTypeNameCase(text: string): string { + return this.toPascalCase(text); + } + public toIdentifier(text: string): string { // kebab-case and "spaced name" to camelCase const parts = text.split(/[ -]/g); @@ -65,7 +72,18 @@ export default class CSharpEmitterContext { } public isPropertySymbol(tsSymbol: ts.Symbol) { - return (tsSymbol.flags & ts.SymbolFlags.Property) !== 0; + if ( + (tsSymbol.flags & (ts.SymbolFlags.Property | ts.SymbolFlags.GetAccessor | ts.SymbolFlags.SetAccessor)) !== 0 + ) { + return true; + } + + // globals + if((tsSymbol.flags & ts.SymbolFlags.FunctionScopedVariable) !== 0 && this.isGlobalVariable(tsSymbol)) { + return true; + } + + return false; } public isTypeAssignable(targetType: ts.Type, contextualTypeNullable: ts.Type, actualType: ts.Type) { @@ -136,7 +154,7 @@ export default class CSharpEmitterContext { if (expr.tsSymbol.flags & ts.SymbolFlags.Function) { if (this.isTestFunction(expr.tsSymbol)) { - return `${this.toPascalCase('alphaTab.test')}.Globals.${this.toPascalCase(expr.tsSymbol.name)}`; + return `${this.toNamespaceNameCase('alphaTab.test')}.Globals.${this.toMethodNameCase(expr.tsSymbol.name)}`; } if (expr.tsSymbol.valueDeclaration && expr.tsNode) { @@ -146,20 +164,20 @@ export default class CSharpEmitterContext { } } - return `${this.toPascalCase('alphaTab.core')}.Globals.${this.toPascalCase(expr.tsSymbol.name)}`; + return `${this.toNamespaceNameCase('alphaTab.core')}.Globals.${this.toMethodNameCase(expr.tsSymbol.name)}`; } if ( (expr.tsSymbol.flags & ts.SymbolFlags.FunctionScopedVariable && this.isGlobalVariable(expr.tsSymbol)) || (expr.tsSymbol.flags & ts.SymbolFlags.NamespaceModule && this.isKnownModule(expr.tsSymbol)) ) { - return `${this.toPascalCase('alphaTab.core')}.Globals.${this.toPascalCase(expr.tsSymbol.name)}`; + return `${this.toNamespaceNameCase('alphaTab.core')}.Globals.${this.toPropertyNameCase(expr.tsSymbol.name)}`; } if (expr.tsSymbol) { const externalModule = this.resolveExternalModuleOfType(expr.tsSymbol); if (externalModule) { - return externalModule + this.toPascalCase(expr.tsSymbol.name); + return externalModule + this.toTypeNameCase(expr.tsSymbol.name); } } } @@ -1292,9 +1310,9 @@ export default class CSharpEmitterContext { result += '.'; } if (i === parts.length - 1) { - result += parts[i]; + result += this.toTypeNameCase(parts[i]); } else { - result += this.toPascalCase(parts[i]); + result += this.toNamespaceNameCase(parts[i]); } } return result; @@ -1305,7 +1323,7 @@ export default class CSharpEmitterContext { if (aliasSymbol) { if (aliasSymbol.name === 'Map') { - return `${this.toPascalCase('alphaTab.collections') + suffix}.`; + return `${this.toNamespaceNameCase('alphaTab.collections') + suffix}.`; } if (aliasSymbol.name === 'Error') { @@ -1320,18 +1338,18 @@ export default class CSharpEmitterContext { if (fileName.length) { suffix = fileName.split('.').map(s => { if (s.match(/webworker/i)) { - return `.${this.toPascalCase('ecmaScript')}`; + return `.${this.toNamespaceNameCase('ecmaScript')}`; } if (s.match(/esnext/)) { - return `.${this.toPascalCase('ecmaScript')}`; + return `.${this.toNamespaceNameCase('ecmaScript')}`; } if (s.match(/es[0-9]{4}/)) { - return `.${this.toPascalCase('ecmaScript')}`; + return `.${this.toNamespaceNameCase('ecmaScript')}`; } if (s.match(/es[0-9]{1}/)) { - return `.${this.toPascalCase('ecmaScript')}`; + return `.${this.toNamespaceNameCase('ecmaScript')}`; } - return `.${this.toPascalCase(s)}`; + return `.${this.toNamespaceNameCase(s)}`; })[0]; } } @@ -1339,7 +1357,7 @@ export default class CSharpEmitterContext { } } - return `${this.toPascalCase('alphaTab.core') + suffix}.`; + return `${this.toNamespaceNameCase('alphaTab.core') + suffix}.`; } protected toCoreTypeName(s: string) { if (s === 'Map') { @@ -1353,10 +1371,6 @@ export default class CSharpEmitterContext { return this.kebabCaseToPascalCase(text); } - if (this.noPascalCase) { - return text; - } - if (!text) { return ''; } @@ -1468,7 +1482,7 @@ export default class CSharpEmitterContext { } if (symbol.name === 'iterator' && (!parent || parent.name === 'SymbolConstructor')) { - return this.toMethodName('getEnumerator'); + return this.toMethodNameCase('getEnumerator'); } return ''; @@ -2125,7 +2139,10 @@ export default class CSharpEmitterContext { } public getDefaultUsings(): string[] { - return [this.toPascalCase('system'), `${this.toPascalCase('alphaTab')}.${this.toPascalCase('core')}`]; + return [ + this.toNamespaceNameCase('system'), + `${this.toNamespaceNameCase('alphaTab')}.${this.toNamespaceNameCase('core')}` + ]; } public buildMethodName(propertyName: ts.PropertyName) { @@ -2155,7 +2172,7 @@ export default class CSharpEmitterContext { this.addTsNodeDiagnostics(propertyName, 'Unsupported method name syntax', ts.DiagnosticCategory.Error); } - return this.toMethodName(methodName); + return this.toMethodNameCase(methodName); } public isSymbolArrayTupleInstance(expression: ts.Expression) { diff --git a/packages/transpiler/src/kotlin/KotlinAstPrinter.ts b/packages/transpiler/src/kotlin/KotlinAstPrinter.ts index 5f77d5643..e1da51960 100644 --- a/packages/transpiler/src/kotlin/KotlinAstPrinter.ts +++ b/packages/transpiler/src/kotlin/KotlinAstPrinter.ts @@ -241,6 +241,7 @@ export default class KotlinAstPrinter extends AstPrinterBase { } protected writeInterfaceDeclaration(d: cs.InterfaceDeclaration) { + this._currentType = d; this.writeDocumentation(d); this.writeLine('@kotlin.contracts.ExperimentalContracts'); @@ -264,9 +265,11 @@ export default class KotlinAstPrinter extends AstPrinterBase { } this.endBlock(); + this._currentType = undefined; } protected writeEnumDeclaration(d: cs.EnumDeclaration) { + this._currentType = d; this._forceInteger = true; this.writeDocumentation(d); this.writeAttributes(d); @@ -332,9 +335,12 @@ export default class KotlinAstPrinter extends AstPrinterBase { this.endBlock(); this._forceInteger = false; + this._currentType = undefined; } + private _currentType: cs.NamedTypeDeclaration | undefined = undefined; protected writeClassDeclaration(d: cs.ClassDeclaration) { + this._currentType = d; this.writeDocumentation(d); this.writeAttributes(d); this.writeLine('@kotlin.contracts.ExperimentalContracts'); @@ -465,6 +471,7 @@ export default class KotlinAstPrinter extends AstPrinterBase { } this.endBlock(); + this._currentType = undefined; } protected writeAttribute(a: cs.Attribute): void { @@ -523,6 +530,7 @@ export default class KotlinAstPrinter extends AstPrinterBase { this.write(' = iterator'); this._thisScope.push((d.parent as cs.NamedTypeDeclaration).name); } + this.writeBody(d.body); if (d.isGeneratorFunction) { this._thisScope.pop(); @@ -627,7 +635,9 @@ export default class KotlinAstPrinter extends AstPrinterBase { this.writeType(d.type); const needsInitializer = - isAutoProperty && d.type.isNullable && d.parent!.nodeType !== cs.SyntaxKind.InterfaceDeclaration && + isAutoProperty && + d.type.isNullable && + d.parent!.nodeType !== cs.SyntaxKind.InterfaceDeclaration && !d.isAbstract; let initializerWritten = false; @@ -1372,6 +1382,25 @@ export default class KotlinAstPrinter extends AstPrinterBase { this.write('('); this.writeCommaSeparated(expr.arguments, a => this.writeExpression(a)); this.write(')'); + + if (expr.objectInitializers?.length) { + this.write('.apply '); + this.beginBlock(); + + this._thisScope.push(this._currentType!.name); + + for (const a of expr.objectInitializers!) { + this.write('this.'); + this.write(a.label); + this.write(' = '); + this.writeExpression(a.expression); + this.writeLine(); + } + + this._thisScope.pop(); + + this.endBlock(); + } } protected writeCastExpression(expr: cs.CastExpression) { @@ -2043,6 +2072,14 @@ export default class KotlinAstPrinter extends AstPrinterBase { this.write('>'); } this.writeBlock(expr.arguments[0] as cs.Block); + } else if ( + expr.arguments.length === 1 && + cs.isMemberAccessExpression(expr.expression) && + expr.expression.member === 'suspendToDeferred' + ) { + this._thisScope.push(this._currentType!.name); + super.writeInvocationExpression(expr); + this._thisScope.pop(); } else { super.writeInvocationExpression(expr); } diff --git a/packages/transpiler/src/kotlin/KotlinAstTransformer.ts b/packages/transpiler/src/kotlin/KotlinAstTransformer.ts index c2edc4464..ae7844e48 100644 --- a/packages/transpiler/src/kotlin/KotlinAstTransformer.ts +++ b/packages/transpiler/src/kotlin/KotlinAstTransformer.ts @@ -221,7 +221,7 @@ export default class KotlinAstTransformer extends CSharpAstTransformer { } override visitCallExpression(parent: cs.Node, expression: ts.CallExpression) { - const invocation = super.visitCallExpression(parent, expression); + let invocation = super.visitCallExpression(parent, expression); if (!invocation) { return invocation; } @@ -252,7 +252,7 @@ export default class KotlinAstTransformer extends CSharpAstTransformer { (body.right.text === 'undefined' || body.right.text === 'null'))) ) { (invocation.expression as cs.MemberAccessExpression).member = - this.context.toMethodName('filterNotNull'); + this.context.toMethodNameCase('filterNotNull'); invocation.arguments = []; } } @@ -272,10 +272,11 @@ export default class KotlinAstTransformer extends CSharpAstTransformer { nodeType: cs.SyntaxKind.InvocationExpression } as cs.InvocationExpression; + suspendToDeferred.expression = this.makeMemberAccess( suspendToDeferred, this.context.makeTypeName('alphaTab.core.TypeHelper'), - this.context.toMethodName('suspendToDeferred') + this.context.toMethodNameCase('suspendToDeferred') ); suspendToDeferred.arguments = [ @@ -730,4 +731,64 @@ export default class KotlinAstTransformer extends CSharpAstTransformer { return d; } + + protected override createDiscriminatedUnionClass( + node: ts.TypeAliasDeclaration, + className: string, + memberType: ts.Type, + baseInterface: cs.InterfaceDeclaration, + discriminatorField: string, + discriminatorValue: string + ): cs.ClassDeclaration { + const csClass = super.createDiscriminatedUnionClass( + node, + className, + memberType, + baseInterface, + discriminatorField, + discriminatorValue + ); + + // need initializers or late init + for (const p of csClass.members) { + if (cs.isPropertyDeclaration(p)) { + if (!p.initializer) { + const tsProp = p.tsNode as ts.PropertyDeclaration; + const type = this.context.getType(tsProp); + const isNullable = this.context.isNullableType(type); + if (type === this.context.typeChecker.getNumberType()) { + p.initializer = { + nodeType: cs.SyntaxKind.NumericLiteral, + parent: p, + value: '0.0' + } as cs.NumericLiteral; + } else if (type === this.context.typeChecker.getBooleanType()) { + p.initializer = { + nodeType: cs.SyntaxKind.FalseLiteral, + parent: p + } as cs.BooleanLiteral; + } else if (isNullable || type === this.context.typeChecker.getUnknownType()) { + // default null + p.initializer = { + nodeType: cs.SyntaxKind.NullLiteral, + parent: p + } as cs.NullLiteral; + } else if (!isNullable) { + // lateinit + p.initializer = { + nodeType: cs.SyntaxKind.NonNullExpression, + parent: p, + expression: { + nodeType: cs.SyntaxKind.NullLiteral + } as cs.NullLiteral + } as cs.NonNullExpression; + } + } else if (p.name === this.context.toPropertyNameCase(discriminatorField)) { + p.isOverride = true; + } + } + } + + return csClass; + } } diff --git a/packages/transpiler/src/kotlin/KotlinEmitterContext.ts b/packages/transpiler/src/kotlin/KotlinEmitterContext.ts index e77a87c9d..d4bf22b91 100644 --- a/packages/transpiler/src/kotlin/KotlinEmitterContext.ts +++ b/packages/transpiler/src/kotlin/KotlinEmitterContext.ts @@ -3,11 +3,6 @@ import * as cs from '../csharp/CSharpAst'; import CSharpEmitterContext from '../csharp/CSharpEmitterContext'; export default class KotlinEmitterContext extends CSharpEmitterContext { - public constructor(program: ts.Program, srcOutDir: string, testOutDir: string) { - super(program, srcOutDir, testOutDir); - this.noPascalCase = true; - } - public override get targetTag(): string { return 'kotlin'; } @@ -41,7 +36,7 @@ export default class KotlinEmitterContext extends CSharpEmitterContext { } public override getDefaultUsings(): string[] { - return [`${this.toPascalCase('alphaTab')}.${this.toPascalCase('core')}`]; + return [`${this.toNamespaceNameCase('alphaTab')}.${this.toNamespaceNameCase('core')}`]; } public override makeExceptionType(): string { @@ -77,7 +72,7 @@ export default class KotlinEmitterContext extends CSharpEmitterContext { } if (symbol.name === 'iterator' && (!parent || parent.name === 'SymbolConstructor')) { - return this.toMethodName('iterator'); + return this.toMethodNameCase('iterator'); } return ''; @@ -94,4 +89,20 @@ export default class KotlinEmitterContext extends CSharpEmitterContext { public override makeIteratorType(): string { return this.makeTypeName('kotlin.collections.Iterator'); } + + public override toMethodNameCase(text: string): string { + return this.toIdentifier(text); + } + + public override toPropertyNameCase(text: string): string { + return this.toIdentifier(text); + } + + public override toNamespaceNameCase(text: string): string { + return text; + } + + public override toTypeNameCase(text: string): string { + return this.toPascalCase(text); + } } From d193376917eb53e8f9c0817c8cfdb9bfdf06c744 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Wed, 8 Apr 2026 21:52:33 +0200 Subject: [PATCH 05/11] fix: ensure correct test logic --- packages/alphatab/test/TestPlatform.ts | 11 ++++++++ .../alphatab/test/visualTests/TestUiFacade.ts | 13 ++------- .../test/visualTests/VisualTestHelper.ts | 2 +- .../csharp/src/AlphaTab.Test/TestPlatform.cs | 28 +++++++++++++++++-- .../java/alphaTab/TestPlatformPartialsImpl.kt | 17 +++++++++++ 5 files changed, 56 insertions(+), 15 deletions(-) diff --git a/packages/alphatab/test/TestPlatform.ts b/packages/alphatab/test/TestPlatform.ts index f887fb796..14ccd0018 100644 --- a/packages/alphatab/test/TestPlatform.ts +++ b/packages/alphatab/test/TestPlatform.ts @@ -7,6 +7,17 @@ import path from 'node:path'; * @internal */ export class TestPlatform { + /** + * @target web + * @partial + */ + public static throttle(action: () => void, delay: number): () => void { + let timeoutId: NodeJS.Timeout | undefined = undefined; + return () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(action, delay); + }; + } /** * @target web * @partial diff --git a/packages/alphatab/test/visualTests/TestUiFacade.ts b/packages/alphatab/test/visualTests/TestUiFacade.ts index 539a9174b..1b8ad03dd 100644 --- a/packages/alphatab/test/visualTests/TestUiFacade.ts +++ b/packages/alphatab/test/visualTests/TestUiFacade.ts @@ -106,13 +106,6 @@ class TestUiContainer implements IContainer { this.childNodes = []; } - public throttle(action: () => void, _delay: number): () => void { - // no throttling. - return () => { - action(); - }; - } - public resize: IEventEmitter = new EventEmitter(); public mouseDown: IEventEmitterOfT = new EventEmitterOfT(); @@ -338,10 +331,8 @@ export class TestUiFacade implements IUiFacade { return null; } - public throttle(action: () => void, _delay: number): () => void { - return () => { - action(); - }; + public throttle(action: () => void, delay: number): () => void { + return TestPlatform.throttle(action, delay); } public readonly canRenderChanged: IEventEmitter = new EventEmitter(); diff --git a/packages/alphatab/test/visualTests/VisualTestHelper.ts b/packages/alphatab/test/visualTests/VisualTestHelper.ts index d840d46d7..996744259 100644 --- a/packages/alphatab/test/visualTests/VisualTestHelper.ts +++ b/packages/alphatab/test/visualTests/VisualTestHelper.ts @@ -198,7 +198,7 @@ export class VisualTestHelper { throw errors[0]; } if (errors.length > 0) { - const errorMessages = errors.map(e => e.message ?? 'Unknown error').join('\n'); + const errorMessages = errors.map(e => `${e.message ?? 'Unknown error'}\n${e.stack}`).join('\n'); throw new Error(errorMessages); } } finally { diff --git a/packages/csharp/src/AlphaTab.Test/TestPlatform.cs b/packages/csharp/src/AlphaTab.Test/TestPlatform.cs index c8ca09f64..1115b5acd 100644 --- a/packages/csharp/src/AlphaTab.Test/TestPlatform.cs +++ b/packages/csharp/src/AlphaTab.Test/TestPlatform.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections; using System.Collections.Generic; using System.IO; @@ -6,6 +6,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using System.Threading; using System.Threading.Tasks; namespace AlphaTab; @@ -29,6 +30,23 @@ static partial class TestPlatform $"Could not find repository root via working dir {System.Environment.CurrentDirectory}"); }); + public static Action Throttle(Action action, double delay) + { + CancellationTokenSource? cancellationTokenSource = null; + return () => + { + cancellationTokenSource?.Cancel(); + cancellationTokenSource = new CancellationTokenSource(); + + Task.Run(async () => + { + await Task.Delay((int)delay, cancellationTokenSource.Token); + action(); + }, + cancellationTokenSource.Token); + }; + } + public static async Task LoadFile(string path) { await using var fs = @@ -120,6 +138,7 @@ public override ArrayTuple Read( { throw new JsonException(); } + var v0 = _keyConverter.Read(ref reader, _keyType, options)!; if (!reader.Read()) @@ -136,7 +155,8 @@ public override ArrayTuple Read( return new ArrayTuple(v0, v1); } - public override void Write(Utf8JsonWriter writer, ArrayTuple value, JsonSerializerOptions options) + public override void Write(Utf8JsonWriter writer, ArrayTuple value, + JsonSerializerOptions options) { writer.WriteStartArray(); _keyConverter.Write(writer, value.V0, options); @@ -167,7 +187,8 @@ public static async Task SaveFileAsString(string name, string data) { var path = Path.Combine(AlphaTabProjectRoot.Value, name); Directory.CreateDirectory(Path.GetDirectoryName(path)!); - await using var fs = new StreamWriter(new FileStream(path, FileMode.Create, FileAccess.ReadWrite)); + await using var fs = + new StreamWriter(new FileStream(path, FileMode.Create, FileAccess.ReadWrite)); await fs.WriteAsync(data); } @@ -272,6 +293,7 @@ public static string CurrentTestName { return ""; } + var testName = testMethodInfo.MethodInfo.GetCustomAttribute()! .DisplayName; return testName ?? ""; diff --git a/packages/kotlin/src/android/src/test/java/alphaTab/TestPlatformPartialsImpl.kt b/packages/kotlin/src/android/src/test/java/alphaTab/TestPlatformPartialsImpl.kt index cb8aa34c4..4f61d4461 100644 --- a/packages/kotlin/src/android/src/test/java/alphaTab/TestPlatformPartialsImpl.kt +++ b/packages/kotlin/src/android/src/test/java/alphaTab/TestPlatformPartialsImpl.kt @@ -23,6 +23,11 @@ import com.beust.klaxon.JsonValue import com.beust.klaxon.Klaxon import com.beust.klaxon.KlaxonException import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.junit.Assert import java.io.ByteArrayOutputStream import java.io.File @@ -325,6 +330,18 @@ class TestPlatformPartials { return testMethod!! } + private val throttleScope = CoroutineScope(Dispatchers.Default) + internal fun throttle(toThrottle: () -> Unit, delay: Double): () -> Unit { + var job: Job? = null + return { + job?.cancel() + job = throttleScope.launch { + delay(delay.toLong()) + toThrottle() + } + } + } + internal val currentTestName: String get() { val testMethodInfo = findTestMethod() From 572e1b8ee197960a0f856b63a7c04af9ebab0552 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Wed, 8 Apr 2026 23:50:53 +0200 Subject: [PATCH 06/11] fix: avoid invalid animation durations due to sync --- packages/alphatab/src/AlphaTabApiBase.ts | 43 +++++++++++++++--------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 69a3138f6..749e011e0 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -352,14 +352,16 @@ export class AlphaTabApiBase { this.container = uiFacade.rootContainer; this.activeBeatsChanged = new EventEmitterOfT(() => { - if (this._player.state === PlayerState.Playing && this._currentBeat) { - return new ActiveBeatsChangedEventArgs(this._currentBeat!.beatLookup.highlightedBeats.map(h => h.beat)); + const currentBeat = this._currentBeat; + if (this._player.state === PlayerState.Playing && currentBeat) { + return new ActiveBeatsChangedEventArgs(currentBeat.beatLookup.highlightedBeats.map(h => h.beat)); } return null; }); this.playedBeatChanged = new EventEmitterOfT(() => { - if (this._player.state === PlayerState.Playing && this._currentBeat) { - return this._currentBeat.beat; + const currentBeat = this._currentBeat; + if (this._player.state === PlayerState.Playing && currentBeat) { + return currentBeat.beat; } return null; }); @@ -2192,8 +2194,9 @@ export class AlphaTabApiBase { this._isInitialBeatCursorUpdate = true; } - if (this._currentBeat !== null) { - this._cursorUpdateBeat(this._currentBeat!, false, this._previousTick > 10, 1, true); + const currentBeat = this._currentBeat; + if (currentBeat) { + this._cursorUpdateBeat(currentBeat, false, this._previousTick > 10, 1, true); } } @@ -2351,7 +2354,15 @@ export class AlphaTabApiBase { this._previousStateForCursor = this._player.state; this.uiFacade.beginInvoke(() => { - this._internalCursorUpdateBeat(lookupResult, stop, cache!, beatBoundings!, shouldScroll, cursorSpeed); + this._internalCursorUpdateBeat( + lookupResult, + stop, + cache!, + beatBoundings!, + shouldScroll, + cursorSpeed, + forceUpdate + ); }); } @@ -2376,7 +2387,8 @@ export class AlphaTabApiBase { boundsLookup: BoundsLookup, beatBoundings: BeatBounds, shouldScroll: boolean, - cursorSpeed: number + cursorSpeed: number, + forceUpdate: boolean ) { const beat = lookupResult.beat; const nextBeat = lookupResult.nextBeat?.beat; @@ -2418,9 +2430,12 @@ export class AlphaTabApiBase { let startBeatX = beatBoundings.onNotesX; if (beatCursor) { const animationWidth = nextBeatX - beatBoundings.onNotesX; - const relativePosition = this._previousTick - this._currentBeat!.start; - const ratioPosition = - this._currentBeat!.tickDuration > 0 ? relativePosition / this._currentBeat!.tickDuration : 0; + const relativePosition = this._previousTick - lookupResult!.start; + let ratioPosition = lookupResult.tickDuration > 0 ? relativePosition / lookupResult.tickDuration : 0; + // state got out-of-sync + if (ratioPosition > 1) { + ratioPosition = 1; + } startBeatX = beatBoundings.onNotesX + animationWidth * ratioPosition; duration -= duration * ratioPosition; @@ -2431,6 +2446,7 @@ export class AlphaTabApiBase { // we do not "reset" the cursor if we are smoothly moving from left to right. const jumpCursor = !previousBeatBounds || + forceUpdate || this._isInitialBeatCursorUpdate || barBounds.y !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.y || startBeatX < previousBeatBounds.onNotesX || @@ -4024,12 +4040,9 @@ export class AlphaTabApiBase { return; } - const currentTick = e.currentTick; - - this._previousTick = currentTick; this.uiFacade.beginInvoke(() => { const cursorSpeed = e.modifiedTempo / e.originalTempo; - this._cursorUpdateTick(currentTick, false, cursorSpeed, false, e.isSeek); + this._cursorUpdateTick(e.currentTick, false, cursorSpeed, false, e.isSeek); }); this.uiFacade.triggerEvent(this.container, 'playerPositionChanged', e); From 74ec593617a44d74480584e2097c64d318b943ed Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Thu, 9 Apr 2026 00:05:49 +0200 Subject: [PATCH 07/11] fix: ensure skia is rendered in background thread --- packages/alphatab/src/Environment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/alphatab/src/Environment.ts b/packages/alphatab/src/Environment.ts index 92d0cea78..70cebd901 100644 --- a/packages/alphatab/src/Environment.ts +++ b/packages/alphatab/src/Environment.ts @@ -411,7 +411,7 @@ export class Environment { renderEngines.set( 'skia', - new RenderEngineFactory(false, () => { + new RenderEngineFactory(true, () => { return new SkiaCanvas(); }) ); From 1397e6db32f7eb9816cfce358e5508c9884b3d1c Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Thu, 9 Apr 2026 00:06:02 +0200 Subject: [PATCH 08/11] fix: ensure we start the worklet port --- .../src/platform/javascript/AlphaSynthAudioWorkletOutput.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts b/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts index 6e9628def..6af13395c 100644 --- a/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts +++ b/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts @@ -14,7 +14,7 @@ import { CircularSampleBuffer } from '@coderline/alphatab/synth/ds/CircularSampl * @target web * @internal */ -type AudioWorkletProcessorMessagePort = Omit, 'terminate'>; +type AudioWorkletProcessorMessagePort = Omit, 'terminate'> & Pick; /** * @target web @@ -95,6 +95,7 @@ export class AlphaSynthWebWorklet { ); this.port.addEventListener('message', e => this._handleMessage(e)); + this.port.start(); } private _handleMessage(e: MessageEvent) { @@ -229,6 +230,7 @@ export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase { }) as AudioWorkletNode; this._worklet.port.addEventListener('message', this._boundHandleMessage); + this._worklet.port.start(); this.source!.connect(this._worklet); this.source!.start(0); this._worklet.connect(ctx!.destination); From c4bee9bc876ba70f5cdecf882a6c0dc252f366a8 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Thu, 9 Apr 2026 00:08:17 +0200 Subject: [PATCH 09/11] fix: various android findings --- .../src/platform/worker/AlphaSynthWebWorkerApi.ts | 2 +- .../src/platform/worker/AlphaTabWorkerProtocol.ts | 10 +++++----- .../src/main/java/alphaTab/EnvironmentPartials.kt | 6 +++--- .../alphaTab/platform/android/JavaThreadWorkers.kt | 3 ++- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts b/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts index 8d4f8bc0d..2c757fd2c 100644 --- a/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts @@ -396,7 +396,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { ); break; case 'alphaSynth.playbackRangeChanged': - this._playbackRange = (data as PlaybackRangeChangedEventArgs).playbackRange; + this._playbackRange = data.playbackRange; (this.playbackRangeChanged as EventEmitterOfT).trigger( new PlaybackRangeChangedEventArgs(this._playbackRange) ); diff --git a/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts b/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts index c29afe0fe..c855a0780 100644 --- a/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts @@ -63,11 +63,11 @@ export type IAlphaSynthWorkerMessage = /* main -> worker */ | { cmd: 'alphaSynth.initialize'; sampleRate: number; logLevel: LogLevel; bufferTimeInMilliseconds: number } | { cmd: 'alphaSynth.setLogLevel'; value: LogLevel } - | { cmd: 'alphaSynth.setMasterVolume'; value: LogLevel } - | { cmd: 'alphaSynth.setMetronomeVolume'; value: LogLevel } - | { cmd: 'alphaSynth.setPlaybackSpeed'; value: LogLevel } - | { cmd: 'alphaSynth.setTickPosition'; value: LogLevel } - | { cmd: 'alphaSynth.setTimePosition'; value: LogLevel } + | { cmd: 'alphaSynth.setMasterVolume'; value: number } + | { cmd: 'alphaSynth.setMetronomeVolume'; value: number } + | { cmd: 'alphaSynth.setPlaybackSpeed'; value: number } + | { cmd: 'alphaSynth.setTickPosition'; value: number } + | { cmd: 'alphaSynth.setTimePosition'; value: number } | { cmd: 'alphaSynth.setPlaybackRange'; value: PlaybackRange | null } | { cmd: 'alphaSynth.setIsLooping'; value: boolean } | { cmd: 'alphaSynth.setCountInVolume'; value: number } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt b/packages/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt index ce7868fde..d2b5ee8f6 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt @@ -47,9 +47,9 @@ internal class EnvironmentPartials { internal inline fun getGlobalWorkerScope(): IAlphaTabWorkerGlobalScope { @Suppress("UNCHECKED_CAST") return when (T::class) { - IAlphaSynthWorkerMessage::class::class -> JavaThreadAlphaSynthWorker.currentThreadWorker as IAlphaTabWorkerGlobalScope - IAlphaTabWorkerMessage::class::class -> JavaThreadAlphaTabRendererWorker.currentThreadWorker as IAlphaTabWorkerGlobalScope - else -> throw UnsupportedOperationException("Unsupported worker scope kind") + IAlphaSynthWorkerMessage::class -> JavaThreadAlphaSynthWorker.currentThreadWorker as IAlphaTabWorkerGlobalScope + IAlphaTabWorkerMessage::class -> JavaThreadAlphaTabRendererWorker.currentThreadWorker as IAlphaTabWorkerGlobalScope + else -> throw UnsupportedOperationException("Unsupported worker scope kind ${T::class::qualifiedName}") } } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt index 8d5dc4b0e..7671fc81b 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt @@ -2,6 +2,7 @@ package alphaTab.platform.android import alphaTab.core.ecmaScript.MessageEvent import alphaTab.platform.worker.AlphaSynthWebWorker +import alphaTab.platform.worker.AlphaTabWebWorker import alphaTab.platform.worker.IAlphaSynthWorker import alphaTab.platform.worker.IAlphaSynthWorkerMessage import alphaTab.platform.worker.IAlphaTabRenderingWorker @@ -124,7 +125,7 @@ internal class JavaThreadAlphaTabRendererWorker(postToMain: (action: () -> Unit) @OptIn(ExperimentalContracts::class, ExperimentalUnsignedTypes::class) override fun onStartInsideWorker() { workerLookup[Thread.currentThread().id] = this; - AlphaSynthWebWorker.init(); + AlphaTabWebWorker.init(); } } From dd6d21beeca8cc488366fd2fd524af8ffe5ae3df Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Thu, 9 Apr 2026 01:14:51 +0200 Subject: [PATCH 10/11] fix: threading bits --- .../src/AlphaTab.Windows/Wpf/AlphaTab.cs | 44 +++++++++++++------ .../Wpf/FrameworkElementContainer.cs | 13 +++++- .../src/AlphaTab.Windows/Wpf/WpfUiFacade.cs | 14 +++++- .../CSharp/ManagedThreadAlphaSynthWorker.cs | 13 +++--- .../platform/android/JavaThreadWorkers.kt | 12 ++--- 5 files changed, 67 insertions(+), 29 deletions(-) diff --git a/packages/csharp/src/AlphaTab.Windows/Wpf/AlphaTab.cs b/packages/csharp/src/AlphaTab.Windows/Wpf/AlphaTab.cs index 4c7668ebc..190306b9f 100644 --- a/packages/csharp/src/AlphaTab.Windows/Wpf/AlphaTab.cs +++ b/packages/csharp/src/AlphaTab.Windows/Wpf/AlphaTab.cs @@ -29,8 +29,8 @@ static AlphaTab() /// Identifies the dependency property. /// public static readonly DependencyProperty TracksProperty = - DependencyProperty.Register("Tracks", typeof(IEnumerable), typeof(AlphaTab), - new PropertyMetadata(default(IEnumerable), OnTracksChanged)); + DependencyProperty.Register(nameof(Tracks), typeof(IEnumerable), typeof(AlphaTab), + new PropertyMetadata(null, OnTracksChanged)); private static void OnTracksChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) @@ -38,16 +38,16 @@ private static void OnTracksChanged(DependencyObject d, var observable = e.OldValue as INotifyCollectionChanged; if (observable != null) { - ((AlphaTab) d).UnregisterObservableCollection(observable); + ((AlphaTab)d).UnregisterObservableCollection(observable); } observable = e.NewValue as INotifyCollectionChanged; if (observable != null) { - ((AlphaTab) d).RegisterObservableCollection(observable); + ((AlphaTab)d).RegisterObservableCollection(observable); } - ((AlphaTab) d).RenderTracks(); + ((AlphaTab)d).RenderTracks(); } private void RegisterObservableCollection(INotifyCollectionChanged collection) @@ -66,9 +66,9 @@ private void OnTracksChanged(object? sender, NotifyCollectionChangedEventArgs e) } /// - public IEnumerable Tracks + public IEnumerable? Tracks { - get => (IEnumerable) GetValue(TracksProperty); + get => (IEnumerable)GetValue(TracksProperty); set => SetValue(TracksProperty, value); } @@ -86,13 +86,13 @@ public IEnumerable Tracks private static void OnSettingsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) { - ((AlphaTab) d).SettingsChanged?.Invoke((Settings) e.NewValue); + ((AlphaTab)d).SettingsChanged?.Invoke((Settings)e.NewValue); } /// public Settings Settings { - get => (Settings) GetValue(SettingsProperty); + get => (Settings)GetValue(SettingsProperty); set => SetValue(SettingsProperty, value); } @@ -113,7 +113,7 @@ public Settings Settings /// public Brush BarCursorFill { - get => (Brush) GetValue(BarCursorFillProperty); + get => (Brush)GetValue(BarCursorFillProperty); set => SetValue(BarCursorFillProperty, value); } @@ -134,7 +134,7 @@ public Brush BarCursorFill /// public Brush BeatCursorFill { - get => (Brush) GetValue(BeatCursorFillProperty); + get => (Brush)GetValue(BeatCursorFillProperty); set => SetValue(BeatCursorFillProperty, value); } @@ -155,16 +155,32 @@ public Brush BeatCursorFill /// public Brush SelectionFill { - get => (Brush) GetValue(SelectionCursorFillProperty); + get => (Brush)GetValue(SelectionCursorFillProperty); set => SetValue(SelectionCursorFillProperty, value); } #endregion + private static readonly DependencyPropertyKey ApiKey = + DependencyProperty.RegisterReadOnly( + nameof(Api), + typeof(AlphaTabApiBase), + typeof(AlphaTab), + new FrameworkPropertyMetadata(null)); + + /// + /// Identifies the property. + /// + public static readonly DependencyProperty ApiProperty = ApiKey.DependencyProperty; + /// /// Gets the alphaTab API object. /// - public AlphaTabApiBase Api { get; private set; } + public AlphaTabApiBase? Api + { + get => GetValue(ApiProperty) as AlphaTabApiBase; + private set => SetValue(ApiKey, value); + } /// /// Initializes a new instance of the class. @@ -184,7 +200,7 @@ public AlphaTab() public override void OnApplyTemplate() { base.OnApplyTemplate(); - _scrollView = (ScrollViewer) Template.FindName("PART_ScrollView", this); + _scrollView = (ScrollViewer)Template.FindName("PART_ScrollView", this); Api = new AlphaTabApiBase(new WpfUiFacade(_scrollView), this); } diff --git a/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs b/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs index 13af754de..b6ee7c317 100644 --- a/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs +++ b/packages/csharp/src/AlphaTab.Windows/Wpf/FrameworkElementContainer.cs @@ -120,6 +120,7 @@ public void AppendChild(IContainer child) private double _targetX = 0; + public void StopAnimation() { Control.Dispatcher.BeginInvoke((Action)(() => @@ -134,8 +135,16 @@ public void TransitionToX(double duration, double x) _targetX = x; Control.Dispatcher.BeginInvoke((Action)(() => { - Control.BeginAnimation(Canvas.LeftProperty, - new DoubleAnimation(x, new Duration(TimeSpan.FromMilliseconds(duration)))); + if (duration > 0) + { + Control.BeginAnimation(Canvas.LeftProperty, + new DoubleAnimation(x, new Duration(TimeSpan.FromMilliseconds(duration)))); + } + else + { + Control.BeginAnimation(Canvas.LeftProperty, null); + Canvas.SetLeft(Control, x); + } })); } diff --git a/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs b/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs index 648d4a861..b4a760c8d 100644 --- a/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs +++ b/packages/csharp/src/AlphaTab.Windows/Wpf/WpfUiFacade.cs @@ -32,10 +32,22 @@ internal class WpfUiFacade : ManagedUiFacade public override IEventEmitter RootContainerBecameVisible { get; } + private static double GetDpiScale(Visual visual) + { + var source = PresentationSource.FromVisual(visual); + if (source?.CompositionTarget != null) + { + var transformToDevice = source.CompositionTarget.TransformToDevice; + return transformToDevice.M11; + } + return 1.0; + } + public WpfUiFacade(ScrollViewer scrollViewer) { _scrollViewer = scrollViewer; RootContainer = new FrameworkElementContainer(scrollViewer); + Environment.HighDpiFactor = GetDpiScale(scrollViewer); RootContainerBecameVisible = new DelegatedEventEmitter( value => { @@ -215,7 +227,7 @@ public override void BeginAppendRenderResults(RenderFinishedEventArgs? r) { placeholder = new Image { - Stretch = Stretch.None, + Stretch = Stretch.Fill, SnapsToDevicePixels = true }; panel.Children.Add(placeholder); diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs index c7d7ace5f..03d384480 100644 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs +++ b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Concurrent; using System.Threading; -using AlphaTab.Collections; using AlphaTab.Platform.Worker; namespace AlphaTab.Platform.CSharp; @@ -13,8 +12,8 @@ internal abstract class ManagedThreadWorkerBase : IAlphaTabWorker private readonly BlockingCollection _workerQueue; private readonly CancellationTokenSource _workerCancellationToken; private readonly ManualResetEventSlim? _threadStartedEvent; - private readonly List>> _listenerInsideWorker = new(); - private readonly List>> _listenerOutsideWorker = new(); + private readonly ConcurrentDictionary>,Action>> _listenerInsideWorker = new(); + private readonly ConcurrentDictionary>,Action>> _listenerOutsideWorker = new(); protected ManagedThreadWorkerBase(Action postToMain) { @@ -63,7 +62,7 @@ public void PostMessage(T message) { foreach (var listener in _listenerOutsideWorker) { - listener(ev); + listener.Value(ev); } }); } @@ -74,7 +73,7 @@ public void PostMessage(T message) { foreach (var listener in _listenerInsideWorker) { - listener(ev); + listener.Value(ev); } }); } @@ -91,7 +90,7 @@ public void AddEventListener(string @event, Action> handler) var listeners = Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId ? _listenerInsideWorker : _listenerOutsideWorker; - listeners.Add(handler); + listeners[handler] = handler; } public void RemoveEventListener(string @event, Action> handler) @@ -100,7 +99,7 @@ public void RemoveEventListener(string @event, Action> handler) var listeners = Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId ? _listenerInsideWorker : _listenerOutsideWorker; - listeners.Remove(handler); + listeners.TryRemove(handler, out _); } public virtual void Terminate() diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt index 7671fc81b..0fb0b1e6b 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt @@ -21,8 +21,10 @@ internal abstract class JavaThreadWorkerBase : IAlphaTabWorker, Runnable { private val _workerQueue = LinkedBlockingQueue<() -> Unit>() private var _isCancelled = false private val _threadStartedEvent = Semaphore(1) - private val _listenerInsideWorker = ArrayList<(ev: MessageEvent) -> Unit>() - private val _listenerOutsideWorker = ArrayList<(ev: MessageEvent) -> Unit>() + private val _listenerInsideWorker = + ConcurrentHashMap<(ev: MessageEvent) -> Unit, (ev: MessageEvent) -> Unit>() + private val _listenerOutsideWorker = + ConcurrentHashMap<(ev: MessageEvent) -> Unit, (ev: MessageEvent) -> Unit>() protected constructor(postToMain: (action: () -> Unit) -> Unit) { _postToMain = postToMain; @@ -59,7 +61,7 @@ internal abstract class JavaThreadWorkerBase : IAlphaTabWorker, Runnable { _postToMain( { for (listener in _listenerOutsideWorker) { - listener(ev); + listener.value(ev); } }); } else { @@ -67,7 +69,7 @@ internal abstract class JavaThreadWorkerBase : IAlphaTabWorker, Runnable { postToWorker( { for (listener in _listenerInsideWorker) { - listener(ev); + listener.value(ev); } }); } @@ -86,7 +88,7 @@ internal abstract class JavaThreadWorkerBase : IAlphaTabWorker, Runnable { } else { _listenerOutsideWorker }; - listeners.add(handler); + listeners[handler] = handler; } override fun removeEventListener(event: String, handler: (arg1: MessageEvent) -> Unit) { From 435c32f3b00771a845edae7d5549c794807e26f6 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Thu, 9 Apr 2026 01:26:39 +0200 Subject: [PATCH 11/11] perf: drain queues --- .../AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs | 4 ++++ .../main/java/alphaTab/platform/android/JavaThreadWorkers.kt | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs index 03d384480..3971f2997 100644 --- a/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs +++ b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs @@ -106,6 +106,10 @@ public virtual void Terminate() { _workerCancellationToken.Cancel(); _workerThread.Join(); + while (_workerQueue.Count > 0) + { + _workerQueue.Take(); + } } } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt index 0fb0b1e6b..4a638d342 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt @@ -107,6 +107,7 @@ internal abstract class JavaThreadWorkerBase : IAlphaTabWorker, Runnable { _isCancelled = true _workerThread.interrupt() _workerThread.join() + _workerQueue.clear() } }