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(