Skip to content

FFTAudioAnalyzer crashes with NPE in processFFT — analyzer is mutated from multiple threads without synchronization #84

Description

@max-buster

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 processFFTsrcBuffer.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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions