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/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 6efea4815..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; }); @@ -395,7 +397,7 @@ export class AlphaTabApiBase { } this.container.resize.on( - Environment.throttle(() => { + this.uiFacade.throttle(() => { if (this._isDestroyed) { return; } @@ -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); diff --git a/packages/alphatab/src/Environment.ts b/packages/alphatab/src/Environment.ts index 6eb7d74cb..70cebd901 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/javascript/AlphaSynthWebWorker'; -import { AlphaTabWebWorker } from '@coderline/alphatab/platform/javascript/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'; @@ -167,6 +171,15 @@ export class Environment { return Environment._globalThis; } + /** + * @target web + * @internal + * @partial + */ + public static getGlobalWorkerScope(): IAlphaTabWorkerGlobalScope { + return Environment.globalThis; + } + /** * @target web */ @@ -206,30 +219,6 @@ export class Environment { return 'AudioWorkletGlobalScope' in Environment.globalThis; } - /** - * @target web - * @internal - */ - public static createWebWorker: (settings: Settings) => Worker; - - /** - * @target web - * @internal - */ - public static createAudioWorklet: (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 */ @@ -422,7 +411,7 @@ export class Environment { renderEngines.set( 'skia', - new RenderEngineFactory(false, () => { + new RenderEngineFactory(true, () => { return new SkiaCanvas(); }) ); @@ -637,7 +626,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 +639,9 @@ export class Environment { Environment.highDpiFactor = window.devicePixelRatio; } - Environment.createWebWorker = createWebWorker; - Environment.createAudioWorklet = createAudioWorklet; + BrowserUiFacade.createAlphaTabWebWorker = s => createWebWorker(s, 'alphaTab Renderer'); + BrowserUiFacade.createAlphaSynthWebWorker = s => createWebWorker(s, 'alphaSynth Worker'); + BrowserUiFacade.createAlphaSynthAudioWorklet = createAudioWorklet; } /** @@ -682,9 +672,6 @@ export class Environment { } AlphaTabWebWorker.init(); AlphaSynthWebWorker.init(); - Environment.createWebWorker = _ => { - throw new AlphaTabError(AlphaTabErrorType.General, 'Nested workers are not supported'); - }; } /** @@ -828,6 +815,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/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/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 b69d2975a..6af13395c 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/javascript/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'> & Pick; /** * @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,23 @@ export class AlphaSynthWebWorklet { AlphaSynthWebWorkletProcessor.BufferSize * this._bufferCount ); - this.port.onmessage = this._handleMessage.bind(this); + this.port.addEventListener('message', e => this._handleMessage(e)); + this.port.start(); } - 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 +158,7 @@ export class AlphaSynthWebWorklet { } this.port.postMessage({ - cmd: AlphaSynthWorkerSynthOutput.CmdOutputSamplesPlayed, + cmd: 'alphaSynth.output.samplesPlayed', samples: samplesFromBuffer / SynthConstants.AudioChannels }); this._requestBuffers(); @@ -161,7 +180,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 +198,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 +219,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( + BrowserUiFacade.createAlphaSynthAudioWorklet(ctx, this._settings).then( () => { this._worklet = new AudioWorkletNode(ctx!, 'alphatab', { numberOfOutputs: 1, @@ -206,26 +227,28 @@ 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._worklet.port.start(); this.source!.connect(this._worklet); this.source!.start(0); this._worklet.connect(ctx!.destination); }, - reason => { + (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 +258,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 +268,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 faa89586d..c3205d2b4 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,9 @@ 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'; +import { IAlphaTabRenderingWorker, IAlphaSynthWorker } 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: IAlphaTabRenderingWorker | undefined; + try { + worker = BrowserUiFacade.createAlphaTabWebWorker(this._api.settings); + return new AlphaTabWorkerScoreRenderer(this._api, 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: IAlphaSynthWorker | 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: IAlphaSynthWorker | 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) => IAlphaTabRenderingWorker; + + /** + * @internal + */ + public static createAlphaSynthWebWorker: (settings: Settings) => IAlphaSynthWorker; + + /** + * @target web + * @internal + */ + public static createAlphaSynthAudioWorklet: (context: AudioContext, settings: Settings) => Promise; } 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 78% rename from packages/alphatab/src/platform/javascript/AlphaSynthAudioExporterWorkerApi.ts rename to packages/alphatab/src/platform/worker/AlphaSynthAudioExporterWorkerApi.ts index 8ae2ca72c..6f17c4bf9 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 { @@ -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++; @@ -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(); @@ -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; @@ -96,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/javascript/AlphaSynthWebWorker.ts b/packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts similarity index 67% rename from packages/alphatab/src/platform/javascript/AlphaSynthWebWorker.ts rename to packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts index 26f26b464..aecb1beb2 100644 --- a/packages/alphatab/src/platform/javascript/AlphaSynthWebWorker.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWebWorker.ts @@ -1,68 +1,62 @@ -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)); } 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.getGlobalWorkerScope()); } - 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(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()); + 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,21 +133,24 @@ 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(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': - const exporter = this._player.exportAudio( + exporterId = data.exporterId; + exporter = this._player.exportAudio( data.options, JsonConverter.jsObjectToMidiFile(data.midi), data.syncPoints, @@ -168,8 +165,9 @@ export class AlphaSynthWebWorker { break; 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', @@ -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 90% rename from packages/alphatab/src/platform/javascript/AlphaSynthWebWorkerApi.ts rename to packages/alphatab/src/platform/worker/AlphaSynthWebWorkerApi.ts index 3c7a48948..2c757fd2c 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 { + IAlphaSynthWorker, + 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!: IAlphaSynthWorker; 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(): IAlphaSynthWorker { return this._synth; } @@ -221,7 +229,7 @@ export class AlphaSynthWebWorkerApi implements IAlphaSynth { }); } - public constructor(player: ISynthOutput, settings: Settings) { + public constructor(player: ISynthOutput, settings: Settings, synthWorker: IAlphaSynthWorker) { this._workerIsReadyForPlayback = false; this._workerIsReady = false; this._outputIsReady = false; @@ -236,12 +244,8 @@ 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.createWebWorker(settings); - } catch (e) { - Logger.error('AlphaSynth', `Failed to create WebWorker: ${e}`); - } - this._synth.addEventListener('message', this.handleWorkerMessage.bind(this), false); + this._synth = synthWorker; + this._synth.addEventListener('message', e => this.handleWorkerMessage(e)); this._synth.postMessage({ cmd: 'alphaSynth.initialize', sampleRate: this._output.sampleRate, @@ -319,9 +323,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 +366,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,20 +381,12 @@ 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': (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': @@ -403,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) ); @@ -419,15 +412,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 53% rename from packages/alphatab/src/platform/javascript/AlphaSynthWorkerSynthOutput.ts rename to packages/alphatab/src/platform/worker/AlphaSynthWorkerSynthOutput.ts index edac8d5e7..d773bdacb 100644 --- a/packages/alphatab/src/platform/javascript/AlphaSynthWorkerSynthOutput.ts +++ b/packages/alphatab/src/platform/worker/AlphaSynthWorkerSynthOutput.ts @@ -1,56 +1,54 @@ -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 { - 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 _worker!: IWorkerScope; + 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._worker = Environment.globalThis as IWorkerScope; - this._worker.addEventListener('message', this._handleMessage.bind(this)); + 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' }); } - 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; } @@ -61,26 +59,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 +88,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 78% rename from packages/alphatab/src/platform/javascript/AlphaTabWebWorker.ts rename to packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts index 62038c4df..191cdbceb 100644 --- a/packages/alphatab/src/platform/javascript/AlphaTabWebWorker.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWebWorker.ts @@ -3,35 +3,38 @@ 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 + * @internal + * @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)); } public static init(): void { - (Environment.globalThis as any).alphaTabWebWorker = new AlphaTabWebWorker( - Environment.globalThis as IWorkerScope - ); + new AlphaTabWebWorker(Environment.getGlobalWorkerScope()); } - private _handleMessage(e: MessageEvent): void { - const data: any = e.data; - const cmd: any = data ? data.cmd : ''; - switch (cmd) { + private _handleMessage(e: MessageEvent): void { + const data = e.data; + if (!data?.cmd) { + return; + } + switch (data.cmd) { case 'alphaTab.initialize': const settings: Settings = JsonConverter.jsObjectToSettings(data.settings); Logger.logLevel = settings.core.logLevel; @@ -82,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; @@ -92,19 +95,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..c855a0780 --- /dev/null +++ b/packages/alphatab/src/platform/worker/AlphaTabWorkerProtocol.ts @@ -0,0 +1,142 @@ +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 }; + +/** + * @internal + */ +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; +} + +/** + * @internal + */ +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: 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 } + | { 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.stop' } + | { cmd: 'alphaSynth.output.destroy' } + | { cmd: 'alphaSynth.output.resetSamples' }; + +/** + * @internal + */ +export interface IAlphaTabRenderingWorker extends IAlphaTabWorker {} + +/** + * @internal + */ +export interface IAlphaSynthWorker extends IAlphaTabWorker {} diff --git a/packages/alphatab/src/platform/javascript/AlphaTabWorkerScoreRenderer.ts b/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts similarity index 84% rename from packages/alphatab/src/platform/javascript/AlphaTabWorkerScoreRenderer.ts rename to packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts index 0ef29268e..92e5b1bd4 100644 --- a/packages/alphatab/src/platform/javascript/AlphaTabWorkerScoreRenderer.ts +++ b/packages/alphatab/src/platform/worker/AlphaTabWorkerScoreRenderer.ts @@ -1,45 +1,41 @@ 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 { + IAlphaTabRenderingWorker, + 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'; -import { Logger } from '@coderline/alphatab/Logger'; -import { Environment } from '@coderline/alphatab/Environment'; /** - * @target web - * @public + * @internal */ export class AlphaTabWorkerScoreRenderer implements IScoreRenderer { private _api: AlphaTabApiBase; - private _worker!: Worker; + private _worker!: IAlphaTabRenderingWorker; private _width: number = 0; public boundsLookup: BoundsLookup | null = null; - public constructor(api: AlphaTabApiBase, settings: Settings) { + public constructor(api: AlphaTabApiBase, worker: IAlphaTabRenderingWorker) { this._api = api; - - try { - this._worker = Environment.createWebWorker(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) + settings: this._serializeSettingsForWorker(api.settings) }); - this._worker.addEventListener('message', this._handleWorkerMessage.bind(this)); + this._worker.addEventListener('message', e => this._handleWorkerMessage(e)); } public destroy(): void { @@ -53,7 +49,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 +88,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 +106,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 +116,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..396a7837f 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; - const bd: any = beat; + 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; bb.beat = - score.tracks[bd.trackIndex].staves[bd.staffIndex].bars[bd.barIndex].voices[ - bd.voiceIndex - ].beats[bd.beatIndex]; - if (beat.notes) { + 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.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.note = bb.beat.notes[note.get('index') as number]; + 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/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/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'; 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 6e9efc966..1b8ad03dd 100644 --- a/packages/alphatab/test/visualTests/TestUiFacade.ts +++ b/packages/alphatab/test/visualTests/TestUiFacade.ts @@ -331,6 +331,10 @@ export class TestUiFacade implements IUiFacade { return null; } + public throttle(action: () => void, delay: number): () => void { + return TestPlatform.throttle(action, delay); + } + public readonly canRenderChanged: IEventEmitter = new EventEmitter(); public readonly rootContainerBecameVisible: 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/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/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 042051e27..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 => { @@ -109,7 +121,7 @@ protected override ISynthOutput CreateSynthOutput() return new NAudioSynthOutput(); } - public override IAlphaSynth? CreateBackingTrackPlayer() + public override IAlphaSynth CreateBackingTrackPlayer() { return new BackingTrackPlayer( new NAudioBackingTrackOutput(BeginInvoke), @@ -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); @@ -294,7 +306,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..3971f2997 --- /dev/null +++ b/packages/csharp/src/AlphaTab/Platform/CSharp/ManagedThreadAlphaSynthWorker.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +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 ConcurrentDictionary>,Action>> _listenerInsideWorker = new(); + private readonly ConcurrentDictionary>,Action>> _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.Value(ev); + } + }); + } + else + { + // Outside Worker -> Post to worker + PostToWorker(() => + { + foreach (var listener in _listenerInsideWorker) + { + listener.Value(ev); + } + }); + } + } + + public void PostToWorker(Action action) + { + _workerQueue.Add(action); + } + + public void AddEventListener(string @event, Action> handler) + { + if (@event != "message") return; + var listeners = Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId + ? _listenerInsideWorker + : _listenerOutsideWorker; + listeners[handler] = handler; + } + + public void RemoveEventListener(string @event, Action> handler) + { + if (@event != "message") return; + var listeners = Thread.CurrentThread.ManagedThreadId == _workerThread.ManagedThreadId + ? _listenerInsideWorker + : _listenerOutsideWorker; + listeners.TryRemove(handler, out _); + } + + public virtual void Terminate() + { + _workerCancellationToken.Cancel(); + _workerThread.Join(); + while (_workerQueue.Count > 0) + { + _workerQueue.Take(); + } + } +} + +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; + AlphaTabWebWorker.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; + AlphaSynthWebWorker.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/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt b/packages/kotlin/src/android/src/main/java/alphaTab/EnvironmentPartials.kt index d3f534bcc..d2b5ee8f6 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 -> JavaThreadAlphaSynthWorker.currentThreadWorker as IAlphaTabWorkerGlobalScope + IAlphaTabWorkerMessage::class -> JavaThreadAlphaTabRendererWorker.currentThreadWorker as IAlphaTabWorkerGlobalScope + else -> throw UnsupportedOperationException("Unsupported worker scope kind ${T::class::qualifiedName}") + } + } + 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..4a638d342 --- /dev/null +++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/JavaThreadWorkers.kt @@ -0,0 +1,154 @@ +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 +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 = + 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; + + _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.value(ev); + } + }); + } else { + // Outside Worker -> Post to worker + postToWorker( + { + for (listener in _listenerInsideWorker) { + listener.value(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[handler] = 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() + _workerQueue.clear() + } +} + +@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; + AlphaTabWebWorker.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/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() 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/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..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: [] } }; @@ -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!), @@ -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( @@ -427,6 +428,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 +507,208 @@ 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 = this.createDiscriminatedUnionBaseInterface(node, discriminatorField); + 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.toTypeNameCase(p)) + .join(''); + + 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 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.toPropertyNameCase(prop.name), + nodeType: cs.SyntaxKind.PropertyDeclaration, + parent: csClass, + isVirtual: false, + isOverride: false, + isAbstract: false, + isStatic: false, + 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, + skipEmit: false + }; + + csClass.members.push(csProperty); + } + return csClass; + } + + 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 + }; + + 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.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) { if (this.context.isRecord(node)) { this.visitRecordDeclaration(node); @@ -703,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, @@ -833,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 = ( @@ -922,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, @@ -1011,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), @@ -1049,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, @@ -1172,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, @@ -1216,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 @@ -1545,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, @@ -1588,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 = { @@ -1654,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 = { @@ -1808,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, @@ -1963,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; @@ -2371,13 +2576,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, @@ -2859,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 = { @@ -2975,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) { @@ -3032,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)!; @@ -3504,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; @@ -3679,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, @@ -3977,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, @@ -3990,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; } } @@ -4095,6 +4306,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 +4550,104 @@ 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.toPropertyNameCase(p.name.getText()); + assignment.expression = this.visitExpression(assignment, p.initializer)!; + newObject.objectInitializers!.push(assignment); + } else if (ts.isShorthandPropertyAssignment(p)) { + assignment.label = this.context.toPropertyNameCase(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 = { @@ -4343,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 @@ -4380,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 @@ -4406,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); @@ -4436,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 @@ -4512,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, @@ -4626,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)!; @@ -4685,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]); @@ -4799,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) { @@ -4898,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 49ab75bf8..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); } } } @@ -346,6 +364,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 +844,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); } @@ -1280,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; @@ -1293,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') { @@ -1308,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]; } } @@ -1327,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') { @@ -1341,10 +1371,6 @@ export default class CSharpEmitterContext { return this.kebabCaseToPascalCase(text); } - if (this.noPascalCase) { - return text; - } - if (!text) { return ''; } @@ -1387,7 +1413,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 +1452,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 { @@ -1451,7 +1482,7 @@ export default class CSharpEmitterContext { } if (symbol.name === 'iterator' && (!parent || parent.name === 'SymbolConstructor')) { - return this.toMethodName('getEnumerator'); + return this.toMethodNameCase('getEnumerator'); } return ''; @@ -1814,9 +1845,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; @@ -2101,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) { @@ -2131,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); + } }