Skip to content

Commit 2d456f0

Browse files
authored
fix: eliminate unnecessary buffer allocations during audio playback (#2715)
1 parent b6031af commit 2d456f0

4 files changed

Lines changed: 98 additions & 24 deletions

File tree

packages/csharp/src/AlphaTab.Windows/NAudioSynthOutput.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public class NAudioSynthOutput : WaveProvider32, ISynthOutput, IDisposable
2626
private int _bufferCount;
2727
private int _requestedBufferCount;
2828
private ISynthOutputDevice? _device;
29+
private Float32Array? _readWrapper;
2930

3031
/// <inheritdoc />
3132
public double SampleRate => PreferredSampleRate;
@@ -139,13 +140,18 @@ private void RequestBuffers()
139140
/// <inheritdoc />
140141
public override int Read(float[] buffer, int offset, int count)
141142
{
142-
var read = new Float32Array(count);
143-
144-
var samplesFromBuffer = (int)_circularBuffer.Read(read, 0,
145-
System.Math.Min(read.Length, _circularBuffer.Count));
143+
// NAudio reuses the same provider buffer across reads, so cache the
144+
// Float32Array wrapper to avoid a per-call allocation that otherwise
145+
// builds up GC pressure during steady-state playback.
146+
var wrapper = _readWrapper;
147+
if (wrapper == null || wrapper.Data.Array != buffer)
148+
{
149+
wrapper = new Float32Array(buffer);
150+
_readWrapper = wrapper;
151+
}
146152

147-
Buffer.BlockCopy(read.Data.Array!, read.Data.Offset, buffer, offset * sizeof(float),
148-
samplesFromBuffer * sizeof(float));
153+
var samplesFromBuffer = (int)_circularBuffer.Read(wrapper, offset,
154+
System.Math.Min(count, _circularBuffer.Count));
149155

150156
((EventEmitterOfT<double>)SamplesPlayed).Trigger(samplesFromBuffer /
151157
SynthConstants.AudioChannels);

packages/csharp/src/AlphaTab/Core/EcmaScript/Float32Array.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public void Set(Float32Array subarray, double offset)
6868
System.Buffer.BlockCopy(subarray.Data.Array!,
6969
subarray.Data.Offset * sizeof(float),
7070
Data.Array!,
71-
Data.Offset + (int)offset * sizeof(float),
71+
(Data.Offset + (int)offset) * sizeof(float),
7272
subarray.Data.Count * sizeof(float));
7373
}
7474

packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidAudioWorker.kt

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package alphaTab.platform.android
22

33
import android.media.*
44
import java.util.concurrent.*
5+
import java.util.concurrent.atomic.AtomicLong
56
import kotlin.contracts.ExperimentalContracts
7+
import kotlin.math.max
8+
import kotlin.math.min
69

710
@ExperimentalContracts
811
@ExperimentalUnsignedTypes
@@ -61,9 +64,28 @@ internal class AndroidAudioWorker(
6164
val samplesFromBuffer = _output.read(_buffer, 0, _buffer.size)
6265
if (_previousPosition == -1) {
6366
_previousPosition = _track.playbackHeadPosition
67+
_startPosition = _previousPosition
6468
_track.getTimestamp(_timestamp)
6569
}
66-
_track.write(_buffer, 0, samplesFromBuffer, AudioTrack.WRITE_BLOCKING)
70+
val silenceFloats = _buffer.size - samplesFromBuffer
71+
if (silenceFloats > 0) {
72+
_buffer.fill(0f, samplesFromBuffer, _buffer.size)
73+
}
74+
// write() may return less than requested (or a negative AudioTrack.ERROR_*
75+
// code) when the track is paused/stopped/disconnected mid-write. Only credit
76+
// counters for what actually landed in the track to keep them in sync with
77+
// playbackHeadPosition.
78+
val floatsWritten = _track.write(
79+
_buffer, 0, _buffer.size, AudioTrack.WRITE_BLOCKING
80+
)
81+
if (floatsWritten > 0) {
82+
val realFloatsWritten = min(floatsWritten, samplesFromBuffer)
83+
val silenceFloatsWritten = floatsWritten - realFloatsWritten
84+
_totalFramesWrittenToTrack.addAndGet((floatsWritten / 2).toLong())
85+
if (silenceFloatsWritten > 0) {
86+
_silenceFramesWrittenToTrack.addAndGet((silenceFloatsWritten / 2).toLong())
87+
}
88+
}
6789
} else {
6890
_playingSemaphore.acquire() // wait for playing to start
6991
_playingSemaphore.release() // release semaphore for others
@@ -87,7 +109,14 @@ internal class AndroidAudioWorker(
87109

88110
fun play() {
89111
if (_track.playState != AudioTrack.PLAYSTATE_PLAYING) {
90-
_previousPosition = _track.playbackHeadPosition
112+
_previousPosition = -1
113+
_startPosition = -1
114+
_totalFramesWrittenToTrack.set(0)
115+
_silenceFramesWrittenToTrack.set(0)
116+
_silenceFramesAccountedAsPlayed = 0
117+
_lastTimestampUpdateNanos = -1L
118+
_timestamp.nanoTime = 0
119+
_timestamp.framePosition = 0
91120
_track.play()
92121
_stopped = false
93122

@@ -110,14 +139,23 @@ internal class AndroidAudioWorker(
110139
}
111140
}
112141

113-
private var _previousPosition: Int = -1
142+
@Volatile private var _previousPosition: Int = -1
143+
@Volatile private var _startPosition: Int = -1
144+
private val _totalFramesWrittenToTrack = AtomicLong(0)
145+
private val _silenceFramesWrittenToTrack = AtomicLong(0)
146+
private var _silenceFramesAccountedAsPlayed: Long = 0
114147
private val _timestamp = AudioTimestamp()
115-
private val _lastTimestampUpdate: Long = -1L
148+
private var _lastTimestampUpdateNanos: Long = -1L
116149

117150
private fun onUpdatePlayedSamples() {
118-
val sinceUpdateInMillis = (System.nanoTime() - _lastTimestampUpdate) / 10e6
119-
if (sinceUpdateInMillis >= 10000) {
120-
if (!_track.getTimestamp(_timestamp)) {
151+
val now = System.nanoTime()
152+
val sinceUpdateMs =
153+
if (_lastTimestampUpdateNanos == -1L) Long.MAX_VALUE
154+
else (now - _lastTimestampUpdateNanos) / 1_000_000L
155+
if (sinceUpdateMs >= 10_000L) {
156+
if (_track.getTimestamp(_timestamp)) {
157+
_lastTimestampUpdateNanos = now
158+
} else {
121159
_timestamp.nanoTime = 0
122160
_timestamp.framePosition = 0
123161
}
@@ -133,12 +171,35 @@ internal class AndroidAudioWorker(
133171
return
134172
}
135173

136-
val playedSamples = samplePosition - _previousPosition
137-
if (playedSamples < 0) {
174+
val rawDelta = samplePosition - _previousPosition
175+
if (rawDelta < 0) {
138176
return
139177
}
140-
141178
_previousPosition = samplePosition
142-
_output.onSamplesPlayed(playedSamples)
179+
180+
val silenceWritten = _silenceFramesWrittenToTrack.get()
181+
if (silenceWritten == 0L) {
182+
// Happy path: synth has kept the ring buffer fed the entire session — no silence
183+
// has ever been queued. Behavior is bit-identical to the pre-fix logic.
184+
if (rawDelta > 0) {
185+
_output.onSamplesPlayed(rawDelta)
186+
}
187+
return
188+
}
189+
190+
// Slow path: writer has silence-padded at least once this session. Compensate for
191+
// silence the head has now crossed; mathematically equivalent to capping the
192+
// cumulative reported count at the cumulative real frames written.
193+
val totalWritten = _totalFramesWrittenToTrack.get()
194+
val realWritten = totalWritten - silenceWritten
195+
val headFromStart = (samplePosition - _startPosition).toLong()
196+
val silencePlayedCum = max(0L, headFromStart - realWritten)
197+
val silenceCrossedThisTick = silencePlayedCum - _silenceFramesAccountedAsPlayed
198+
_silenceFramesAccountedAsPlayed = silencePlayedCum
199+
200+
val realDelta = rawDelta.toLong() - silenceCrossedThisTick
201+
if (realDelta > 0) {
202+
_output.onSamplesPlayed(realDelta.toInt())
203+
}
143204
}
144205
}

packages/kotlin/src/android/src/main/java/alphaTab/platform/android/AndroidSynthOutput.kt

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ internal class AndroidSynthOutput(
3232

3333
private lateinit var _audioContext: AndroidAudioWorker
3434
private lateinit var _circularBuffer: CircularSampleBuffer
35+
private var _readWrapper: Float32Array? = null
3536

3637
override val sampleRate: Double
3738
get() = PreferredSampleRate.toDouble()
@@ -108,12 +109,18 @@ internal class AndroidSynthOutput(
108109
}
109110

110111
fun read(buffer: FloatArray, offset: Int, sampleCount: Int): Int {
111-
val read = Float32Array(sampleCount.toDouble())
112-
val actual = _circularBuffer.read(read, 0.0, min(read.length, _circularBuffer.count))
113-
114-
read.data.copyInto(buffer, offset, 0, sampleCount)
112+
// AndroidAudioWorker has one static buffer which is reused, we can cache the read wrapper
113+
var wrapper = _readWrapper
114+
if (wrapper == null || wrapper.data !== buffer) {
115+
wrapper = Float32Array(buffer)
116+
_readWrapper = wrapper
117+
}
118+
val actual = _circularBuffer.read(
119+
wrapper,
120+
offset.toDouble(),
121+
min(sampleCount.toDouble(), _circularBuffer.count)
122+
)
115123
requestBuffers()
116-
117124
return actual.toInt()
118125
}
119126

@@ -122,7 +129,7 @@ internal class AndroidSynthOutput(
122129
override val sampleRequest: IEventEmitter = EventEmitter()
123130

124131
override suspend fun enumerateOutputDevices(): List<ISynthOutputDevice> {
125-
val audioService = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager?
132+
val audioService = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager?
126133
?: return List()
127134

128135
return List(

0 commit comments

Comments
 (0)