Summary
AudioBarVisualizer / AudioVisualizer can crash with a NullPointerException inside FFTAudioAnalyzer.processFFT when the composable is disposed (or its track
changes) while audio is still being processed. The crash is a data race: FFTAudioAnalyzer is driven from three different coroutines plus a DisposableEffect, with no
synchronization between them.
Versions
io.livekit:livekit-android-compose-components:2.3.0
io.livekit:livekit-android:2.26.0
- Reproduced on a release build (R8 enabled).
Stack trace
Fatal Exception: java.lang.NullPointerException: Attempt to invoke virtual method
'java.lang.Class java.lang.Object.getClass()' on a null object reference
at io.livekit.android.compose.ui.audio.FFTAudioAnalyzer.processFFT(FFTAudioAnalyzer.java:170)
at io.livekit.android.compose.ui.audio.FFTAudioAnalyzer.queueInput(FFTAudioAnalyzer.java:143)
at io.livekit.android.compose.ui.audio.AudioBarVisualizerKt$AudioVisualizer$3$1$1.emit(AudioBarVisualizer.kt:90)
at io.livekit.android.compose.ui.audio.AudioBarVisualizerKt$AudioVisualizer$3$1$1.emit(AudioBarVisualizer.kt:89)
at kotlinx.coroutines.flow.SharedFlowImpl.collect$suspendImpl(SharedFlow.kt:397)
...
at kotlinx.coroutines.scheduling.CoroutineScheduler$Worker.run(CoroutineScheduler.kt:704)
The crash thread is a DefaultDispatcher worker — i.e. the launch(Dispatchers.IO) collector in AudioVisualizer that feeds queueInput.
Root cause
In AudioVisualizer the single FFTAudioAnalyzer instance is touched from three places, keyed on audioTrackRef:
configure(audioFormat) — runs on the composition (main) coroutine
queueInput(buffer) → processFFT(...) — runs on Dispatchers.IO
release() — runs on main inside DisposableEffect's onDispose
processFFT checks noise once at the top, then dereferences it again ~22 lines later:
private fun processFFT(buffer: ByteBuffer) {
if (noise == null) return // guard passes...
srcBuffer.put(buffer.array())
...
val fft = noise?.fft(src, dst)!! // L170: release() nulled `noise` in between → !! throws NPE
mutableFftFlow.tryEmit(fft)
}
When release() runs on main (composable disposed, or audioTrackRef changed) and sets noise = null while an IO-thread processFFT is mid-flight, the trailing
noise?.fft(src, dst)!! evaluates to null and the !! assertion throws. (The getClass()-on-null message is just how R8 surfaces the Kotlin null-assertion intrinsic
in release builds.)
Relevant lines on main:
There is a second, related race in configure(): it sets noise (which flips isActive to true) before allocating the lateinit var srcBuffer:
fun configure(inputAudioFormat: AudioFormat) {
this.inputAudioFormat = inputAudioFormat
noise = Noise.real(SAMPLE_SIZE) // isActive == true here...
audioTrackBufferSize = getDefaultBufferSizeInBytes(inputAudioFormat)
srcBuffer = ByteBuffer.allocate(...) // ...but srcBuffer not assigned until here
}
A concurrent queueInput can pass the if (!isActive) return check and reach processFFT → srcBuffer.put(...) before srcBuffer is initialized.
Reproduction
Place an AudioBarVisualizer whose mount/unmount toggles rapidly against a live audio track — e.g. a push-to-talk button that only composes the visualizer while pressed:
if (isPressed) {
AudioBarVisualizer(audioTrackRef = liveMicTrackRef, ...)
} else {
Icon(...)
}
Each release disposes the visualizer → release() on main while the IO collector is still draining a buffer → intermittent NPE at processFFT. It also reproduces by
navigating away from the screen mid-transmission.
Suggested fix
Serialize access to the analyzer's mutable state so release() cannot null noise/srcBuffer while processFFT runs. Minimally:
- Capture
noise into a local at the top of processFFT and use that local throughout (so it can't change mid-method), e.g. val noise = this.noise ?: return.
- Guard
configure() / release() / processFFT with a common lock (or make the fields @Volatile and reorder configure() so noise is assigned last, after
srcBuffer).
Summary
AudioBarVisualizer/AudioVisualizercan crash with aNullPointerExceptioninsideFFTAudioAnalyzer.processFFTwhen the composable is disposed (or its trackchanges) while audio is still being processed. The crash is a data race:
FFTAudioAnalyzeris driven from three different coroutines plus aDisposableEffect, with nosynchronization between them.
Versions
io.livekit:livekit-android-compose-components:2.3.0io.livekit:livekit-android:2.26.0Stack trace
The crash thread is a
DefaultDispatcherworker — i.e. thelaunch(Dispatchers.IO)collector inAudioVisualizerthat feedsqueueInput.Root cause
In
AudioVisualizerthe singleFFTAudioAnalyzerinstance is touched from three places, keyed onaudioTrackRef:configure(audioFormat)— runs on the composition (main) coroutinequeueInput(buffer)→processFFT(...)— runs onDispatchers.IOrelease()— runs on main insideDisposableEffect'sonDisposeprocessFFTchecksnoiseonce at the top, then dereferences it again ~22 lines later:When
release()runs on main (composable disposed, oraudioTrackRefchanged) and setsnoise = nullwhile an IO-threadprocessFFTis mid-flight, the trailingnoise?.fft(src, dst)!!evaluates tonulland the!!assertion throws. (ThegetClass()-on-null message is just how R8 surfaces the Kotlin null-assertion intrinsicin release builds.)
Relevant lines on
main:FFTAudioAnalyzer.kt#L170release()nullsnoiseunsynchronized:#L103-L106onDispose:AudioBarVisualizer.kt#L67-L99There is a second, related race in
configure(): it setsnoise(which flipsisActiveto true) before allocating thelateinit var srcBuffer:A concurrent
queueInputcan pass theif (!isActive) returncheck and reachprocessFFT→srcBuffer.put(...)beforesrcBufferis initialized.Reproduction
Place an
AudioBarVisualizerwhose mount/unmount toggles rapidly against a live audio track — e.g. a push-to-talk button that only composes the visualizer while pressed:Each release disposes the visualizer →
release()on main while the IO collector is still draining a buffer → intermittent NPE atprocessFFT. It also reproduces bynavigating away from the screen mid-transmission.
Suggested fix
Serialize access to the analyzer's mutable state so
release()cannot nullnoise/srcBufferwhileprocessFFTruns. Minimally:noiseinto a local at the top ofprocessFFTand use that local throughout (so it can't change mid-method), e.g.val noise = this.noise ?: return.configure()/release()/processFFTwith a common lock (or make the fields@Volatileand reorderconfigure()sonoiseis assigned last, aftersrcBuffer).