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(