diff --git a/packages/alphatab/src/midi/MidiFileGenerator.ts b/packages/alphatab/src/midi/MidiFileGenerator.ts
index dacb3ac59..7649d0b89 100644
--- a/packages/alphatab/src/midi/MidiFileGenerator.ts
+++ b/packages/alphatab/src/midi/MidiFileGenerator.ts
@@ -853,12 +853,18 @@ export class MidiFileGenerator {
// e.g. the eighth notes on a 4/4 time signature must start exactly on the following
// times to get a triplet feel applied
// 0 480 960 1440 1920 2400 2880 3360
- if (beatStart % interval !== 0) {
+ const pairSlot = interval * 2;
+ if (beatStart % pairSlot !== 0) {
return null;
}
// ensure next beat matches spec
- if (!beat.nextBeat || beat.nextBeat.voice !== beat.voice || beat.playbackDuration !== interval) {
+ if (
+ !beat.nextBeat ||
+ beat.nextBeat.voice !== beat.voice ||
+ beat.nextBeat.playbackDuration !== interval ||
+ beat.nextBeat.playbackStart !== beatStart + interval
+ ) {
return null;
}
diff --git a/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts b/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts
index 204aef546..0198c896a 100644
--- a/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts
+++ b/packages/alphatab/src/platform/javascript/AlphaSynthAudioWorkletOutput.ts
@@ -220,6 +220,13 @@ export class AlphaSynthAudioWorkletOutput extends AlphaSynthWebAudioOutputBase {
public override play(): void {
super.play();
const ctx = this.context!;
+
+ // clear any pending events buffered from previous playback rounds
+ // we just want the events which come in after the play call until the worklet is created
+ if (this._pendingEvents) {
+ this._pendingEvents = undefined;
+ }
+
// create a script processor node which will replace the silence with the generated audio
BrowserUiFacade.createAlphaSynthAudioWorklet(ctx, this._settings).then(
() => {
diff --git a/packages/alphatab/src/rendering/utils/BeamingHelper.ts b/packages/alphatab/src/rendering/utils/BeamingHelper.ts
index 17933a77a..151ad7b26 100644
--- a/packages/alphatab/src/rendering/utils/BeamingHelper.ts
+++ b/packages/alphatab/src/rendering/utils/BeamingHelper.ts
@@ -8,7 +8,6 @@ import type { Note } from '@coderline/alphatab/model/Note';
import type { Staff } from '@coderline/alphatab/model/Staff';
import type { Voice } from '@coderline/alphatab/model/Voice';
import type { BarRendererBase } from '@coderline/alphatab/rendering/BarRendererBase';
-import { BeatXPosition } from '@coderline/alphatab/rendering/BeatXPosition';
import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper';
import type { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection';
import type { BeamingRuleLookup } from '@coderline/alphatab/rendering/utils/BeamingRuleLookup';
@@ -107,11 +106,7 @@ export class BeamingHelper {
}
public alignWithBeats() {
- for (const v of this.drawingInfos.values()) {
- v.startX = this._renderer.getBeatX(v.startBeat!, BeatXPosition.Stem);
- v.endX = this._renderer.getBeatX(v.endBeat!, BeatXPosition.Stem);
- this.drawingInfos.clear();
- }
+ this.drawingInfos.clear();
}
public finish(): void {
diff --git a/packages/alphatab/test/audio/MidiFileGenerator.test.ts b/packages/alphatab/test/audio/MidiFileGenerator.test.ts
index 9ade098c5..78664f690 100644
--- a/packages/alphatab/test/audio/MidiFileGenerator.test.ts
+++ b/packages/alphatab/test/audio/MidiFileGenerator.test.ts
@@ -59,9 +59,10 @@ describe('MidiFileGeneratorTest', () => {
for (let i: number = 0; i < actualEvents.length; i++) {
Logger.info('Test', `i[${i}] ${actualEvents[i]}`);
if (i < expectedEvents.length) {
- expect(expectedEvents[i].equals(actualEvents[i]), `i[${i}] expected[${expectedEvents[i]}] !== actual[${actualEvents[i]}]`).toBe(
- true
- );
+ expect(
+ expectedEvents[i].equals(actualEvents[i]),
+ `i[${i}] expected[${expectedEvents[i]}] !== actual[${actualEvents[i]}]`
+ ).toBe(true);
}
}
expect(actualEvents.length).toBe(expectedEvents.length);
@@ -614,54 +615,122 @@ describe('MidiFileGeneratorTest', () => {
assertEvents(handler.midiEvents, expectedEvents);
});
- it('triplet-feel', () => {
- const tex: string =
- '\\ts 2 4 \\tf t8 3.2.8*4 | \\tf t16 3.2.16*8 | \\tf d8 3.2.8*4 | \\tf d16 3.2.16*8 | \\tf s8 3.2.8*4 | \\tf s16 3.2.16*8';
- const score: Score = parseTex(tex);
- // prettier-ignore
- const expectedPlaybackStartTimes: number[] = [
- 0, 480, 960, 1440, 0, 240, 480, 720, 960, 1200, 1440, 1680, 0, 480, 960, 1440, 0, 240, 480, 720, 960, 1200,
- 1440, 1680, 0, 480, 960, 1440, 0, 240, 480, 720, 960, 1200, 1440, 1680
- ];
- // prettier-ignore
- const expectedPlaybackDurations: number[] = [
- 480, 480, 480, 480, 240, 240, 240, 240, 240, 240, 240, 240, 480, 480, 480, 480, 240, 240, 240, 240, 240,
- 240, 240, 240, 480, 480, 480, 480, 240, 240, 240, 240, 240, 240, 240, 240
- ];
- const actualPlaybackStartTimes: number[] = [];
- const actualPlaybackDurations: number[] = [];
- let beat: Beat | null = score.tracks[0].staves[0].bars[0].voices[0].beats[0];
- while (beat) {
- actualPlaybackStartTimes.push(beat.playbackStart);
- actualPlaybackDurations.push(beat.playbackDuration);
- beat = beat.nextBeat;
- }
- expect(actualPlaybackStartTimes.join(',')).toBe(expectedPlaybackStartTimes.join(','));
- expect(actualPlaybackDurations.join(',')).toBe(expectedPlaybackDurations.join(','));
- // prettier-ignore
- const expectedMidiStartTimes: number[] = [
- 0, 640, 960, 1600, 1920, 2240, 2400, 2720, 2880, 3200, 3360, 3680, 3840, 4560, 4800, 5520, 5760, 6120, 6240,
- 6600, 6720, 7080, 7200, 7560, 7680, 7920, 8640, 8880, 9600, 9720, 10080, 10200, 10560, 10680, 11040, 11160
- ];
- // prettier-ignore
- const expectedMidiDurations: number[] = [
- 640, 320, 640, 320, 320, 160, 320, 160, 320, 160, 320, 160, 720, 240, 720, 240, 360, 120, 360, 120, 360,
- 120, 360, 120, 240, 720, 240, 720, 120, 360, 120, 360, 120, 360, 120, 360
- ];
-
- const actualMidiStartTimes: number[] = [];
- const actualMidiDurations: number[] = [];
- const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator();
- const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler);
- generator.generate();
- for (const midiEvent of handler.midiEvents) {
- if (midiEvent instanceof FlatNoteEvent) {
- actualMidiStartTimes.push(midiEvent.tick);
- actualMidiDurations.push(midiEvent.length);
+ describe('triplet-feel', () => {
+ function testTripletFeel(
+ tex: string,
+ expectedPlaybackStartTimes: number[],
+ expectedPlaybackDurations: number[],
+ expectedMidiStartTimes: number[],
+ expectedMidiDurations: number[]
+ ) {
+ const score: Score = parseTex(tex);
+
+ const actualPlaybackStartTimes: number[] = [];
+ const actualPlaybackDurations: number[] = [];
+ let beat: Beat | null = score.tracks[0].staves[0].bars[0].voices[0].beats[0];
+ while (beat) {
+ actualPlaybackStartTimes.push(beat.playbackStart);
+ actualPlaybackDurations.push(beat.playbackDuration);
+ beat = beat.nextBeat;
}
+ expect(actualPlaybackStartTimes.join(','), 'expectedPlaybackStartTimes').toBe(
+ expectedPlaybackStartTimes.join(',')
+ );
+ expect(actualPlaybackDurations.join(','), 'expectedPlaybackDurations').toBe(
+ expectedPlaybackDurations.join(',')
+ );
+
+ const actualMidiStartTimes: number[] = [];
+ const actualMidiDurations: number[] = [];
+ const handler: FlatMidiEventGenerator = new FlatMidiEventGenerator();
+ const generator: MidiFileGenerator = new MidiFileGenerator(score, null, handler);
+ generator.generate();
+ for (const midiEvent of handler.midiEvents) {
+ if (midiEvent instanceof FlatNoteEvent) {
+ actualMidiStartTimes.push(midiEvent.tick);
+ actualMidiDurations.push(midiEvent.length);
+ }
+ }
+ expect(actualMidiStartTimes.join(','), 'expectedMidiStartTimes').toBe(expectedMidiStartTimes.join(','));
+ expect(actualMidiDurations.join(','), 'expectedMidiDurations').toBe(expectedMidiDurations.join(','));
}
- expect(actualMidiStartTimes.join(',')).toBe(expectedMidiStartTimes.join(','));
- expect(actualMidiDurations.join(',')).toBe(expectedMidiDurations.join(','));
+
+ it('variants', () => {
+ const tex: string =
+ '\\ts 2 4 \\tf t8 3.2.8*4 | \\tf t16 3.2.16*8 | \\tf d8 3.2.8*4 | \\tf d16 3.2.16*8 | \\tf s8 3.2.8*4 | \\tf s16 3.2.16*8';
+ const expectedPlaybackStartTimes: number[] = [
+ 0, 480, 960, 1440, 0, 240, 480, 720, 960, 1200, 1440, 1680, 0, 480, 960, 1440, 0, 240, 480, 720, 960,
+ 1200, 1440, 1680, 0, 480, 960, 1440, 0, 240, 480, 720, 960, 1200, 1440, 1680
+ ];
+ const expectedPlaybackDurations: number[] = [
+ 480, 480, 480, 480, 240, 240, 240, 240, 240, 240, 240, 240, 480, 480, 480, 480, 240, 240, 240, 240, 240,
+ 240, 240, 240, 480, 480, 480, 480, 240, 240, 240, 240, 240, 240, 240, 240
+ ];
+ const expectedMidiStartTimes: number[] = [
+ 0, 640, 960, 1600, 1920, 2240, 2400, 2720, 2880, 3200, 3360, 3680, 3840, 4560, 4800, 5520, 5760, 6120,
+ 6240, 6600, 6720, 7080, 7200, 7560, 7680, 7920, 8640, 8880, 9600, 9720, 10080, 10200, 10560, 10680,
+ 11040, 11160
+ ];
+ const expectedMidiDurations: number[] = [
+ 640, 320, 640, 320, 320, 160, 320, 160, 320, 160, 320, 160, 720, 240, 720, 240, 360, 120, 360, 120, 360,
+ 120, 360, 120, 240, 720, 240, 720, 120, 360, 120, 360, 120, 360, 120, 360
+ ];
+ testTripletFeel(
+ tex,
+ expectedPlaybackStartTimes,
+ expectedPlaybackDurations,
+ expectedMidiStartTimes,
+ expectedMidiDurations
+ );
+ });
+
+ it('not-matching-with-grace', () => {
+ testTripletFeel(
+ `
+ \\tf triplet8th
+ 0.1.8
+ 0.1.8{gr onbeat}
+ 0.1.4
+ 0.1.8
+ 0.1.8
+ 0.1.8
+ 0.1.8
+ 0.1.8 |
+ 0.1.8 * 8
+ `,
+ // no swing on playback start/durations
+ [
+ // Bar 1
+ 0, 480, 600, 1440, 1920, 2400, 2880, 3360,
+ // Bar 2
+ 0, 480, 960, 1440, 1920, 2400, 2880, 3360
+ ],
+ [
+ // Bar 1
+ 480, 120, 840, 480, 480, 480, 480, 480,
+ // Bar 2
+ 480, 480, 480, 480, 480, 480, 480, 480
+ ],
+ // swing on generated midi
+ [
+ // Bar 1
+ // no swing on the first 4 notes as they do not align with the swing definition
+ 0, 480, 600, 1440,
+ // the last two 8th note pairs swing
+ 1920, 2560, 2880, 3520,
+ // Bar 2 fully swings
+ 3840, 4480, 4800, 5440, 5760, 6400, 6720, 7360
+ ],
+ [
+ // no swing
+ 480, 120, 840, 480,
+ // the last two 8th note pairs swing
+ 640, 320, 640, 320,
+ // Swing on second bar
+ 640, 320, 640, 320, 640, 320, 640, 320
+ ]
+ );
+ });
});
it('beat-multi-bend', () => {
diff --git a/packages/csharp/src/AlphaTab.Windows/NAudioSynthOutput.cs b/packages/csharp/src/AlphaTab.Windows/NAudioSynthOutput.cs
index 01a1b1ff4..d0e3e1bab 100644
--- a/packages/csharp/src/AlphaTab.Windows/NAudioSynthOutput.cs
+++ b/packages/csharp/src/AlphaTab.Windows/NAudioSynthOutput.cs
@@ -26,6 +26,7 @@ public class NAudioSynthOutput : WaveProvider32, ISynthOutput, IDisposable
private int _bufferCount;
private int _requestedBufferCount;
private ISynthOutputDevice? _device;
+ private Float32Array? _readWrapper;
///
public double SampleRate => PreferredSampleRate;
@@ -139,13 +140,18 @@ private void RequestBuffers()
///
public override int Read(float[] buffer, int offset, int count)
{
- var read = new Float32Array(count);
-
- var samplesFromBuffer = (int)_circularBuffer.Read(read, 0,
- System.Math.Min(read.Length, _circularBuffer.Count));
+ // NAudio reuses the same provider buffer across reads, so cache the
+ // Float32Array wrapper to avoid a per-call allocation that otherwise
+ // builds up GC pressure during steady-state playback.
+ var wrapper = _readWrapper;
+ if (wrapper == null || wrapper.Data.Array != buffer)
+ {
+ wrapper = new Float32Array(buffer);
+ _readWrapper = wrapper;
+ }
- Buffer.BlockCopy(read.Data.Array!, read.Data.Offset, buffer, offset * sizeof(float),
- samplesFromBuffer * sizeof(float));
+ var samplesFromBuffer = (int)_circularBuffer.Read(wrapper, offset,
+ System.Math.Min(count, _circularBuffer.Count));
((EventEmitterOfT)SamplesPlayed).Trigger(samplesFromBuffer /
SynthConstants.AudioChannels);
diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/Float32Array.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Float32Array.cs
index 13fcbaaec..1ddb467b0 100644
--- a/packages/csharp/src/AlphaTab/Core/EcmaScript/Float32Array.cs
+++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/Float32Array.cs
@@ -68,7 +68,7 @@ public void Set(Float32Array subarray, double offset)
System.Buffer.BlockCopy(subarray.Data.Array!,
subarray.Data.Offset * sizeof(float),
Data.Array!,
- Data.Offset + (int)offset * sizeof(float),
+ (Data.Offset + (int)offset) * sizeof(float),
subarray.Data.Count * sizeof(float));
}
diff --git a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidAudioWorker.kt b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidAudioWorker.kt
index 26af6404b..de87df9f9 100644
--- a/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidAudioWorker.kt
+++ b/packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidAudioWorker.kt
@@ -2,7 +2,10 @@ package alphaTab.platform.android
import android.media.*
import java.util.concurrent.*
+import java.util.concurrent.atomic.AtomicLong
import kotlin.contracts.ExperimentalContracts
+import kotlin.math.max
+import kotlin.math.min
@ExperimentalContracts
@ExperimentalUnsignedTypes
@@ -61,9 +64,28 @@ internal class AndroidAudioWorker(
val samplesFromBuffer = _output.read(_buffer, 0, _buffer.size)
if (_previousPosition == -1) {
_previousPosition = _track.playbackHeadPosition
+ _startPosition = _previousPosition
_track.getTimestamp(_timestamp)
}
- _track.write(_buffer, 0, samplesFromBuffer, AudioTrack.WRITE_BLOCKING)
+ val silenceFloats = _buffer.size - samplesFromBuffer
+ if (silenceFloats > 0) {
+ _buffer.fill(0f, samplesFromBuffer, _buffer.size)
+ }
+ // write() may return less than requested (or a negative AudioTrack.ERROR_*
+ // code) when the track is paused/stopped/disconnected mid-write. Only credit
+ // counters for what actually landed in the track to keep them in sync with
+ // playbackHeadPosition.
+ val floatsWritten = _track.write(
+ _buffer, 0, _buffer.size, AudioTrack.WRITE_BLOCKING
+ )
+ if (floatsWritten > 0) {
+ val realFloatsWritten = min(floatsWritten, samplesFromBuffer)
+ val silenceFloatsWritten = floatsWritten - realFloatsWritten
+ _totalFramesWrittenToTrack.addAndGet((floatsWritten / 2).toLong())
+ if (silenceFloatsWritten > 0) {
+ _silenceFramesWrittenToTrack.addAndGet((silenceFloatsWritten / 2).toLong())
+ }
+ }
} else {
_playingSemaphore.acquire() // wait for playing to start
_playingSemaphore.release() // release semaphore for others
@@ -87,7 +109,14 @@ internal class AndroidAudioWorker(
fun play() {
if (_track.playState != AudioTrack.PLAYSTATE_PLAYING) {
- _previousPosition = _track.playbackHeadPosition
+ _previousPosition = -1
+ _startPosition = -1
+ _totalFramesWrittenToTrack.set(0)
+ _silenceFramesWrittenToTrack.set(0)
+ _silenceFramesAccountedAsPlayed = 0
+ _lastTimestampUpdateNanos = -1L
+ _timestamp.nanoTime = 0
+ _timestamp.framePosition = 0
_track.play()
_stopped = false
@@ -110,14 +139,23 @@ internal class AndroidAudioWorker(
}
}
- private var _previousPosition: Int = -1
+ @Volatile private var _previousPosition: Int = -1
+ @Volatile private var _startPosition: Int = -1
+ private val _totalFramesWrittenToTrack = AtomicLong(0)
+ private val _silenceFramesWrittenToTrack = AtomicLong(0)
+ private var _silenceFramesAccountedAsPlayed: Long = 0
private val _timestamp = AudioTimestamp()
- private val _lastTimestampUpdate: Long = -1L
+ private var _lastTimestampUpdateNanos: Long = -1L
private fun onUpdatePlayedSamples() {
- val sinceUpdateInMillis = (System.nanoTime() - _lastTimestampUpdate) / 10e6
- if (sinceUpdateInMillis >= 10000) {
- if (!_track.getTimestamp(_timestamp)) {
+ val now = System.nanoTime()
+ val sinceUpdateMs =
+ if (_lastTimestampUpdateNanos == -1L) Long.MAX_VALUE
+ else (now - _lastTimestampUpdateNanos) / 1_000_000L
+ if (sinceUpdateMs >= 10_000L) {
+ if (_track.getTimestamp(_timestamp)) {
+ _lastTimestampUpdateNanos = now
+ } else {
_timestamp.nanoTime = 0
_timestamp.framePosition = 0
}
@@ -133,12 +171,35 @@ internal class AndroidAudioWorker(
return
}
- val playedSamples = samplePosition - _previousPosition
- if (playedSamples < 0) {
+ val rawDelta = samplePosition - _previousPosition
+ if (rawDelta < 0) {
return
}
-
_previousPosition = samplePosition
- _output.onSamplesPlayed(playedSamples)
+
+ val silenceWritten = _silenceFramesWrittenToTrack.get()
+ if (silenceWritten == 0L) {
+ // Happy path: synth has kept the ring buffer fed the entire session — no silence
+ // has ever been queued. Behavior is bit-identical to the pre-fix logic.
+ if (rawDelta > 0) {
+ _output.onSamplesPlayed(rawDelta)
+ }
+ return
+ }
+
+ // Slow path: writer has silence-padded at least once this session. Compensate for
+ // silence the head has now crossed; mathematically equivalent to capping the
+ // cumulative reported count at the cumulative real frames written.
+ val totalWritten = _totalFramesWrittenToTrack.get()
+ val realWritten = totalWritten - silenceWritten
+ val headFromStart = (samplePosition - _startPosition).toLong()
+ val silencePlayedCum = max(0L, headFromStart - realWritten)
+ val silenceCrossedThisTick = silencePlayedCum - _silenceFramesAccountedAsPlayed
+ _silenceFramesAccountedAsPlayed = silencePlayedCum
+
+ val realDelta = rawDelta.toLong() - silenceCrossedThisTick
+ if (realDelta > 0) {
+ _output.onSamplesPlayed(realDelta.toInt())
+ }
}
}
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 0bd11f42e..8e40effae 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
@@ -32,6 +32,7 @@ internal class AndroidSynthOutput(
private lateinit var _audioContext: AndroidAudioWorker
private lateinit var _circularBuffer: CircularSampleBuffer
+ private var _readWrapper: Float32Array? = null
override val sampleRate: Double
get() = PreferredSampleRate.toDouble()
@@ -108,12 +109,18 @@ internal class AndroidSynthOutput(
}
fun read(buffer: FloatArray, offset: Int, sampleCount: Int): Int {
- val read = Float32Array(sampleCount.toDouble())
- val actual = _circularBuffer.read(read, 0.0, min(read.length, _circularBuffer.count))
-
- read.data.copyInto(buffer, offset, 0, sampleCount)
+ // AndroidAudioWorker has one static buffer which is reused, we can cache the read wrapper
+ var wrapper = _readWrapper
+ if (wrapper == null || wrapper.data !== buffer) {
+ wrapper = Float32Array(buffer)
+ _readWrapper = wrapper
+ }
+ val actual = _circularBuffer.read(
+ wrapper,
+ offset.toDouble(),
+ min(sampleCount.toDouble(), _circularBuffer.count)
+ )
requestBuffers()
-
return actual.toInt()
}
@@ -122,7 +129,7 @@ internal class AndroidSynthOutput(
override val sampleRequest: IEventEmitter = EventEmitter()
override suspend fun enumerateOutputDevices(): List {
- val audioService = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager?
+ val audioService = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager?
?: return List()
return List(