Deep dive into Kotlin Flow buffering and execution model using real-time audio processing as a case study
tkcREALITY, Android Engineer
I refactored the background music (BGM) audio processing in a live streaming app from a manual MediaCodec loop to a Kotlin Flow-based pipeline. I defined three types of classes: Producer (decoding via MediaCodec), Intermediate (frame shaping, channel conversion, and playback position tracking), and Consumer (distribution to local playback and stream transmission), combining them declaratively in a chain using Flow operators. The design was clean, and code readability improved significantly. However, as a result of this implementation, issues surfaced one after another: "BGM suddenly plays at double speed," "Periodic noise appears in transmitted audio," "Audio playback has noise on specific models," and "BGM stops mid-way on specific models"—we couldn't proceed with the release without resolving these. At first, I had no idea what was happening and traced the cause step-by-step backward through the processing stages starting from the data passed to AudioTrack. As a result, while some issues were caused by the Android platform itself, several problems were directly tied to Flow's execution model—where using internal buffering of shareIn or Channel without full understanding allowed upstream processing to advance ahead of downstream, overwriting ring buffer data. In this session, I will focus on the latter—issues caused by a lack of understanding of Flow. The first issue was that BGM played at double speed as soon as Consumer was split using shareIn. In this session, I will explain the internal buffer behavior of shareIn, clarify the conditions under which upstream advances regardless of Subscriber processing speed, and describe how I addressed it. The second issue was audio skipping occurring at points passing through Channel, a bug that occurred only on specific devices. In this session, I will untangle how multiple factors intertwined—such as how AudioTrack's internal buffer size depends on the device's Audio HAL implementation causing frame processing to advance faster than expected, resulting in the entire pipeline running at high speed, along with timing gaps between Channel's send() and tryReceive()—and explain how I dealt with it. Using this process of investigation and fixes as a subject, this session delves deep into Kotlin Flow's buffering and execution model. The internal buffers of shareIn, the send/receive timing of Channel, and the fact that downstream buffer sizes like AudioTrack are device-dependent—I will share primary insights and concrete decision criteria for designing with an awareness of where data is held, when it is referenced, and when it can be overwritten, rather than relying on guesswork. You will gain the necessary perspectives to safely use Flow in domains where real-time performance is required. (Translated by the DroidKaigi Committee)
Intended audience
・Those who routinely use Kotlin Coroutines / Flow in production code ・Those aware that they use Coroutines or Flow behaviors based on guesswork ・Those attempting to use Flow for real-time processing such as audio / video / sensors ・Those interested in the Android audio processing stack (AudioTrack / Audio HAL) ・Those interested in investigating hard-to-pinpoint bugs that reproduce only on specific devices