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(