From 4957f0903b58468f72e57aeee7b8f1a70fb1b857 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Thu, 29 Oct 2026 18:22:17 +0100 Subject: [PATCH] fix(midi): fade via expression and restore it with the voice's next note Fade in, fade out and volume swell ramped the channel volume and never restored it, silencing the rest of the track and overriding volume automations. Fades now ramp the expression controller, and after a fade out or volume swell the expression is restored when the same voice plays its next non-rest beat. Fixes #2978 --- .../alphatab/src/midi/MidiFileGenerator.ts | 102 +++---- .../test/audio/MidiFileGenerator.test.ts | 254 +++++++++++------- .../csharp/src/AlphaTab/Core/TypeHelper.cs | 5 + .../java/alphaTab/collections/DoubleList.kt | 9 + .../main/java/alphaTab/collections/List.kt | 4 + 5 files changed, 228 insertions(+), 146 deletions(-) diff --git a/packages/alphatab/src/midi/MidiFileGenerator.ts b/packages/alphatab/src/midi/MidiFileGenerator.ts index 41c6ecc310..2f8e1dde83 100644 --- a/packages/alphatab/src/midi/MidiFileGenerator.ts +++ b/packages/alphatab/src/midi/MidiFileGenerator.ts @@ -95,6 +95,7 @@ class PlayThroughContext { export class MidiFileGenerator { private static readonly _defaultDurationDead: number = 30; private static readonly _defaultDurationPalmMute: number = 80; + private static readonly _fullExpression: number = 127; private readonly _score: Score; private _settings: Settings; @@ -103,6 +104,7 @@ export class MidiFileGenerator { private _currentTime: number = 0; private _calculatedBeatTimers: Set = new Set(); + private _fadedVoices: Set = new Set(); /** * Gets a lookup object which can be used to quickly find beats and bars @@ -143,6 +145,7 @@ export class MidiFileGenerator { public generate(): void { this.transpositionPitches.clear(); this._calculatedBeatTimers.clear(); + this._fadedVoices.clear(); this._currentTime = 0; // initialize tracks @@ -757,6 +760,14 @@ export class MidiFileGenerator { for (const automation of beat.automations) { this._generateNonTempoAutomation(beat, automation, barStartTick); } + const voiceKey = MidiFileGenerator._voiceKey(beat); + if (!beat.isRest && this._fadedVoices.has(voiceKey)) { + this._fadedVoices.delete(voiceKey); + // a fading beat sets the expression with its own ramp + if (beat.fade === FadeType.None) { + this._addExpression(track, barStartTick + beatStart, MidiFileGenerator._fullExpression); + } + } if (beat.isRest) { this._handler.addRest(track.index, barStartTick + beatStart, track.playbackInfo.primaryChannel); } else if (beat.deadSlapped) { @@ -1314,56 +1325,39 @@ export class MidiFileGenerator { const track: Track = beat.voice.bar.staff.track; switch (beat.fade) { case FadeType.FadeIn: - this._generateFadeSteps( - track, - beatStart, - beatDuration, - 0, - MidiFileGenerator._toChannelShort(track.playbackInfo.volume) - ); - break; + this._generateFadeSteps(track, beatStart, beatDuration, 0, MidiFileGenerator._fullExpression); + return; case FadeType.FadeOut: - this._generateFadeSteps( - track, - beatStart, - beatDuration, - MidiFileGenerator._toChannelShort(track.playbackInfo.volume), - 0 - ); + this._generateFadeSteps(track, beatStart, beatDuration, MidiFileGenerator._fullExpression, 0); break; case FadeType.VolumeSwell: const half = (beatDuration / 2) | 0; - this._generateFadeSteps( - track, - beatStart, - half, - 0, - MidiFileGenerator._toChannelShort(track.playbackInfo.volume) - ); - this._generateFadeSteps( - track, - beatStart + half, - half, - MidiFileGenerator._toChannelShort(track.playbackInfo.volume), - 0 - ); - + this._generateFadeSteps(track, beatStart, half, 0, MidiFileGenerator._fullExpression); + this._generateFadeSteps(track, beatStart + half, half, MidiFileGenerator._fullExpression, 0); break; } + + // the expression is restored with the voice's next non-rest beat, so the release of the faded notes stays silent + this._fadedVoices.add(MidiFileGenerator._voiceKey(beat)); + } + + private static _voiceKey(beat: Beat): string { + const staff = beat.voice.bar.staff; + return `${staff.track.index}:${staff.index}:${beat.voice.index}`; } private _generateFadeSteps( track: Track, start: number, duration: number, - startVolume: number, - endVolume: number + startExpression: number, + endExpression: number ): void { const tickStep: number = 120; // we want to reach the target volume a bit earlier than the end of the note duration = (duration * 0.8) | 0; - const volumeFactor: number = (endVolume - startVolume) / duration; + const expressionFactor: number = (endExpression - startExpression) / duration; const steps: number = (duration / tickStep + 1) | 0; const endTick: number = start + duration; @@ -1372,25 +1366,30 @@ export class MidiFileGenerator { // ensure final value at end depending on rounding we might not reach it exactly const isLast = i === steps - 1; const tick: number = isLast ? endTick : start + i * tickStep; - const volume: number = isLast ? endVolume : Math.round(startVolume + (tick - start) * volumeFactor); - - this._handler.addControlChange( - track.index, - tick, - track.playbackInfo.primaryChannel, - ControllerType.VolumeCoarse, - volume - ); - this._handler.addControlChange( - track.index, - tick, - track.playbackInfo.secondaryChannel, - ControllerType.VolumeCoarse, - volume - ); + const expression: number = isLast + ? endExpression + : Math.round(startExpression + (tick - start) * expressionFactor); + this._addExpression(track, tick, expression); } } + private _addExpression(track: Track, tick: number, expression: number): void { + this._handler.addControlChange( + track.index, + tick, + track.playbackInfo.primaryChannel, + ControllerType.ExpressionControllerCoarse, + expression + ); + this._handler.addControlChange( + track.index, + tick, + track.playbackInfo.secondaryChannel, + ControllerType.ExpressionControllerCoarse, + expression + ); + } + private _generateVibrato( note: Note, noteStart: number, @@ -2446,7 +2445,12 @@ export class MidiFileGenerator { public generateSingleBeat(beat: Beat) { const tempo = this.prepareSingleBeat(beat); + this._fadedVoices.clear(); this._generateBeat(beat, -beat.playbackStart /* to bring it to 0*/, beat.voice.bar, tempo); + if (this._fadedVoices.size > 0) { + this._fadedVoices.clear(); + this._addExpression(beat.voice.bar.staff.track, beat.playbackDuration, MidiFileGenerator._fullExpression); + } } public generateSingleNote(note: Note) { diff --git a/packages/alphatab/test/audio/MidiFileGenerator.test.ts b/packages/alphatab/test/audio/MidiFileGenerator.test.ts index 15b67ca6d7..1e1c944cd2 100644 --- a/packages/alphatab/test/audio/MidiFileGenerator.test.ts +++ b/packages/alphatab/test/audio/MidiFileGenerator.test.ts @@ -7,6 +7,7 @@ import { Logger } from '@coderline/alphatab/Logger'; import { AlphaSynthMidiFileHandler } from '@coderline/alphatab/midi/AlphaSynthMidiFileHandler'; import { ControllerType } from '@coderline/alphatab/midi/ControllerType'; import { + type ControlChangeEvent, type MidiEvent, MidiEventType, NoteOnEvent, @@ -30,8 +31,10 @@ import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { Settings } from '@coderline/alphatab/Settings'; import { AlphaSynth } from '@coderline/alphatab/synth/AlphaSynth'; import { AlphaSynthWrapper } from '@coderline/alphatab/synth/AlphaSynthWrapper'; +import { AudioExportOptions } from '@coderline/alphatab/synth/IAudioExporter'; import { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; +import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; import { FlatControlChangeEvent, type FlatMidiEvent, @@ -1539,132 +1542,189 @@ describe('MidiFileGeneratorTest', () => { assertEvents(actualNoteEvents, expectedEvents); }); - it('fade-in', () => { - const tex: string = '3.3.4 3.3.4 { f }'; - const score: Score = parseTex(tex); - - const info: PlaybackInformation = score.tracks[0].playbackInfo; - const expectedEvents: FlatMidiEvent[] = [ - new FlatControlChangeEvent(960, 0, info.primaryChannel, ControllerType.VolumeCoarse, 0), - new FlatControlChangeEvent(960, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 0), - - new FlatControlChangeEvent(1080, 0, info.primaryChannel, ControllerType.VolumeCoarse, 19), - new FlatControlChangeEvent(1080, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 19), - - new FlatControlChangeEvent(1200, 0, info.primaryChannel, ControllerType.VolumeCoarse, 38), - new FlatControlChangeEvent(1200, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 38), - - new FlatControlChangeEvent(1320, 0, info.primaryChannel, ControllerType.VolumeCoarse, 56), - new FlatControlChangeEvent(1320, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 56), - - new FlatControlChangeEvent(1440, 0, info.primaryChannel, ControllerType.VolumeCoarse, 75), - new FlatControlChangeEvent(1440, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 75), - - new FlatControlChangeEvent(1560, 0, info.primaryChannel, ControllerType.VolumeCoarse, 94), - new FlatControlChangeEvent(1560, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 94), - - new FlatControlChangeEvent(1728, 0, info.primaryChannel, ControllerType.VolumeCoarse, 120), - new FlatControlChangeEvent(1728, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 120) - ]; - + const expressionEvents = (score: Score): FlatMidiEvent[] => { const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator(); const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler); generator.generate(); - const actualNoteEvents: FlatMidiEvent[] = handler.midiEvents.filter( + return handler.midiEvents.filter( e => e instanceof FlatControlChangeEvent && - (e as FlatControlChangeEvent).controller === ControllerType.VolumeCoarse && + (e as FlatControlChangeEvent).controller === ControllerType.ExpressionControllerCoarse && e.tick >= MidiUtils.QuarterTime ); + }; - assertEvents(actualNoteEvents, expectedEvents); - }); + const expressionOnBothChannels = (info: PlaybackInformation, tick: number, value: number): FlatMidiEvent[] => { + return [ + new FlatControlChangeEvent(tick, 0, info.primaryChannel, ControllerType.ExpressionControllerCoarse, value), + new FlatControlChangeEvent(tick, 0, info.secondaryChannel, ControllerType.ExpressionControllerCoarse, value) + ]; + }; - it('fade-out', () => { - const tex: string = '3.3.4 3.3.4 { fo }'; - const score: Score = parseTex(tex); + const generateMidiFile = (score: Score): MidiFile => { + const midiFile = new MidiFile(); + const generator = new MidiFileGenerator(score, null, new AlphaSynthMidiFileHandler(midiFile)); + generator.generate(); + return midiFile; + }; + + // expression values ("tick:value") and note-ons ("tick:on") of the primary channel in playback order + const expressionTimeline = (score: Score, fromTick: number, toTick: number): string[] => { + const channel = score.tracks[0].playbackInfo.primaryChannel; + const timeline: string[] = []; + for (const e of generateMidiFile(score).events) { + if (e.tick < fromTick || e.tick > toTick) { + continue; + } + if (e.type === MidiEventType.NoteOn && (e as NoteOnEvent).channel === channel) { + timeline.push(`${e.tick}:on`); + } else if ( + e.type === MidiEventType.ControlChange && + (e as ControlChangeEvent).channel === channel && + (e as ControlChangeEvent).controller === ControllerType.ExpressionControllerCoarse + ) { + timeline.push(`${e.tick}:${(e as ControlChangeEvent).value}`); + } + } + return timeline; + }; + it('fade-in', () => { + const score: Score = parseTex('3.3.4 3.3.4 { f } 3.3.4'); const info: PlaybackInformation = score.tracks[0].playbackInfo; const expectedEvents: FlatMidiEvent[] = [ - new FlatControlChangeEvent(960, 0, info.primaryChannel, ControllerType.VolumeCoarse, 120), - new FlatControlChangeEvent(960, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 120), - - new FlatControlChangeEvent(1080, 0, info.primaryChannel, ControllerType.VolumeCoarse, 101), - new FlatControlChangeEvent(1080, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 101), - - new FlatControlChangeEvent(1200, 0, info.primaryChannel, ControllerType.VolumeCoarse, 83), - new FlatControlChangeEvent(1200, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 83), - - new FlatControlChangeEvent(1320, 0, info.primaryChannel, ControllerType.VolumeCoarse, 64), - new FlatControlChangeEvent(1320, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 64), - - new FlatControlChangeEvent(1440, 0, info.primaryChannel, ControllerType.VolumeCoarse, 45), - new FlatControlChangeEvent(1440, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 45), + ...expressionOnBothChannels(info, 960, 0), + ...expressionOnBothChannels(info, 1080, 20), + ...expressionOnBothChannels(info, 1200, 40), + ...expressionOnBothChannels(info, 1320, 60), + ...expressionOnBothChannels(info, 1440, 79), + ...expressionOnBothChannels(info, 1560, 99), + ...expressionOnBothChannels(info, 1728, 127) + ]; - new FlatControlChangeEvent(1560, 0, info.primaryChannel, ControllerType.VolumeCoarse, 26), - new FlatControlChangeEvent(1560, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 26), + assertEvents(expressionEvents(score), expectedEvents); + }); - new FlatControlChangeEvent(1728, 0, info.primaryChannel, ControllerType.VolumeCoarse, 0), - new FlatControlChangeEvent(1728, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 0) + it('fade-out', () => { + const score: Score = parseTex('3.3.4 3.3.4 { fo } 3.3.4'); + const info: PlaybackInformation = score.tracks[0].playbackInfo; + const expectedEvents: FlatMidiEvent[] = [ + ...expressionOnBothChannels(info, 960, 127), + ...expressionOnBothChannels(info, 1080, 107), + ...expressionOnBothChannels(info, 1200, 87), + ...expressionOnBothChannels(info, 1320, 67), + ...expressionOnBothChannels(info, 1440, 48), + ...expressionOnBothChannels(info, 1560, 28), + ...expressionOnBothChannels(info, 1728, 0), + // restore before the next note + ...expressionOnBothChannels(info, 1920, 127) ]; - const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator(); - const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler); - generator.generate(); - const actualNoteEvents: FlatMidiEvent[] = handler.midiEvents.filter( - e => - e instanceof FlatControlChangeEvent && - (e as FlatControlChangeEvent).controller === ControllerType.VolumeCoarse && - e.tick >= MidiUtils.QuarterTime - ); - - assertEvents(actualNoteEvents, expectedEvents); + assertEvents(expressionEvents(score), expectedEvents); }); it('volume-swell', () => { - const tex: string = '3.3.4 3.3.4 { vs }'; - const score: Score = parseTex(tex); - + const score: Score = parseTex('3.3.4 3.3.4 { vs } 3.3.4'); const info: PlaybackInformation = score.tracks[0].playbackInfo; const expectedEvents: FlatMidiEvent[] = [ // fade-in - new FlatControlChangeEvent(960, 0, info.primaryChannel, ControllerType.VolumeCoarse, 0), - new FlatControlChangeEvent(960, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 0), - - new FlatControlChangeEvent(1080, 0, info.primaryChannel, ControllerType.VolumeCoarse, 38), - new FlatControlChangeEvent(1080, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 38), - - new FlatControlChangeEvent(1200, 0, info.primaryChannel, ControllerType.VolumeCoarse, 75), - new FlatControlChangeEvent(1200, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 75), + ...expressionOnBothChannels(info, 960, 0), + ...expressionOnBothChannels(info, 1080, 40), + ...expressionOnBothChannels(info, 1200, 79), + ...expressionOnBothChannels(info, 1344, 127), + // fade-out + ...expressionOnBothChannels(info, 1440, 127), + ...expressionOnBothChannels(info, 1560, 87), + ...expressionOnBothChannels(info, 1680, 48), + ...expressionOnBothChannels(info, 1824, 0), + // restore before the next note + ...expressionOnBothChannels(info, 1920, 127) + ]; - new FlatControlChangeEvent(1344, 0, info.primaryChannel, ControllerType.VolumeCoarse, 120), - new FlatControlChangeEvent(1344, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 120), + assertEvents(expressionEvents(score), expectedEvents); + }); - // fade-out - new FlatControlChangeEvent(1440, 0, info.primaryChannel, ControllerType.VolumeCoarse, 120), - new FlatControlChangeEvent(1440, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 120), + it('fade-out-into-fade-in', () => { + const score: Score = parseTex('3.3.4 { fo } 3.3.4 { f }'); + expect(expressionTimeline(score, 768, 1080)).toEqual(['768:0', '960:on', '960:0', '1080:20']); + }); - new FlatControlChangeEvent(1560, 0, info.primaryChannel, ControllerType.VolumeCoarse, 83), - new FlatControlChangeEvent(1560, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 83), + it('fade-keeps-volume-automation', () => { + const score: Score = parseTex('3.3.4 { volume 8 } 3.3.4 { fo } 3.3.4 { f } 3.3.4 { vs } 3.3.4'); + const info: PlaybackInformation = score.tracks[0].playbackInfo; + const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator(); + new MidiFileGenerator(score, null, handler).generate(); + + const volumeEvents = handler.midiEvents + .filter( + e => + e instanceof FlatControlChangeEvent && + (e as FlatControlChangeEvent).controller === ControllerType.VolumeCoarse + ) + .map(e => e as FlatControlChangeEvent); + + for (const channel of [info.primaryChannel, info.secondaryChannel]) { + const channelVolume = volumeEvents.filter(e => e.channel === channel); + expect(channelVolume.every(e => e.tick === 0)).toBe(true); + expect(channelVolume[channelVolume.length - 1].value).toBe(64); + } + }); - new FlatControlChangeEvent(1680, 0, info.primaryChannel, ControllerType.VolumeCoarse, 45), - new FlatControlChangeEvent(1680, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 45), + it('fade-out-restores-after-rest', () => { + const score: Score = parseTex('3.3.4 3.3.4 { fo } r.4 3.3.4 | r.1 | 3.3.4 { fo } r.4 r.2 | r.1 | 3.3.4'); + expect(expressionTimeline(score, 1728, 2880)).toEqual(['1728:0', '2880:127', '2880:on']); + expect(expressionTimeline(score, 8448, 15360)).toEqual(['8448:0', '15360:127', '15360:on']); + }); - new FlatControlChangeEvent(1824, 0, info.primaryChannel, ControllerType.VolumeCoarse, 0), - new FlatControlChangeEvent(1824, 0, info.secondaryChannel, ControllerType.VolumeCoarse, 0) - ]; + it('fade-out-restores-on-repeat', () => { + const score: Score = parseTex('\\ro \\rc 2 3.3.2 3.3.2 { fo } | 5.3.1'); + expect(expressionTimeline(score, 3456, 3840)).toEqual(['3456:0', '3840:127', '3840:on']); + expect(expressionTimeline(score, 7296, 7680)).toEqual(['7296:0', '7680:127', '7680:on']); + }); - const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator(); - const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler); - generator.generate(); - const actualNoteEvents: FlatMidiEvent[] = handler.midiEvents.filter( - e => - e instanceof FlatControlChangeEvent && - (e as FlatControlChangeEvent).controller === ControllerType.VolumeCoarse && - e.tick >= MidiUtils.QuarterTime - ); + it('fade-out-restores-on-scottish-triplet-feel', () => { + const score: Score = parseTex('\\tf scottish8th 3.3.8 { fo } 3.3.8 3.3.4 3.3.2'); + expect(expressionTimeline(score, 192, 480)).toEqual(['192:0', '240:127', '240:on']); + }); - assertEvents(actualNoteEvents, expectedEvents); + it('fade-out-audio-after-fade', async () => { + const score: Score = parseTex('\\tempo 120 . 3.3.4 { fo } r.4 3.3.4 3.3.4'); + const midiFile = generateMidiFile(score); + + const synth = new AlphaSynth(new TestOutput(), 500); + const options = new AudioExportOptions(); + options.sampleRate = 44100; + options.masterVolume = 1; + options.metronomeVolume = 0; + options.soundFonts = [await TestPlatform.loadFile('test-data/audio/default.sf2')]; + const exporter = synth.exportAudio(options, midiFile, [], new Map()); + + const samplesPerMillisecond = (options.sampleRate / 1000) * SynthConstants.AudioChannels; + const samples: number[] = []; + while (samples.length < samplesPerMillisecond * 2000) { + const chunk = exporter.render(100); + if (chunk === undefined) { + break; + } + for (let i = 0; i < chunk.samples.length; i++) { + samples.push(chunk.samples[i]); + } + } + const peak = (startMillis: number, endMillis: number) => { + let max = 0; + for (let i = startMillis * samplesPerMillisecond; i < endMillis * samplesPerMillisecond; i++) { + max = Math.max(max, Math.abs(samples[i])); + } + return max; + }; + + // at 120bpm one quarter note is 500ms + const fadedNote = peak(0, 100); + expect(fadedNote).toBeGreaterThan(0.01); + // the release of the faded note stays silent during the rest + expect(peak(500, 990)).toBeLessThan(fadedNote * 0.01); + expect(peak(1000, 1500)).toBeGreaterThan(fadedNote * 0.5); + expect(peak(1500, 2000)).toBeGreaterThan(fadedNote * 0.5); }); it('ornaments', async () => { diff --git a/packages/csharp/src/AlphaTab/Core/TypeHelper.cs b/packages/csharp/src/AlphaTab/Core/TypeHelper.cs index 6259980895..1a33bacef6 100644 --- a/packages/csharp/src/AlphaTab/Core/TypeHelper.cs +++ b/packages/csharp/src/AlphaTab/Core/TypeHelper.cs @@ -99,6 +99,11 @@ public static bool Some(this IList list, Func predicate) return list.Any(predicate); } + public static bool Every(this IList list, Func predicate) + { + return list.All(predicate); + } + public static IList Splice(this IList data, double start) { var deleteCount = data.Count - (int)start; diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/collections/DoubleList.kt b/packages/kotlin/src/android/src/main/java/alphaTab/collections/DoubleList.kt index 4a627859fc..3878e437fb 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/collections/DoubleList.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/collections/DoubleList.kt @@ -197,4 +197,13 @@ public class DoubleList : IDoubleIterable { } return false } + + public fun every(predicate: (Double) -> Boolean): Boolean { + for (el in this) { + if(!predicate(el)) { + return false + } + } + return true + } } diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt b/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt index 8d1dbc0ba3..b2cce9d7dd 100644 --- a/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt +++ b/packages/kotlin/src/android/src/main/java/alphaTab/collections/List.kt @@ -95,6 +95,10 @@ public class List : Iterable { return _data.any(predicate) } + public fun every(predicate: (T) -> Boolean): Boolean { + return _data.all(predicate) + } + public fun includes(value: T): Boolean { return _data.contains(value) }