- TECHNICAL CASE STUDY

Building Voxa.

Notes on the architecture, decisions, and trade-offs behind Voxa - a soundboard studio for Android.

Platform
Android 9.0+ (API 28)
Stack
Kotlin · Jetpack Compose · ExoPlayer (Media3)
Build time
~ 4 months, evenings & weekends
Released
6th June 2026
Download
Google Play ↗

01 What the app does

Voxa is a soundboard studio for Android. You import sounds, then play them back at a touch of a button. It gives complete control over how those sounds are played - they can be looped, faded in and out, and played over each other or queued. Sounds can be played from multiple soundboards at once, and all stopped at the same time.

The audience is musicians, streamers, podcasters, and anyone who wants reliable instant playback without using a full DAW.

02 Why I built it

I’ve always enjoyed playing around with music production software, and used to have a Roland SP-303 that I would record samples from records on. When I was looking for inspiration for my next app, I noticed the popularity of soundboard apps - but almost all of them came with a fixed set of pre-loaded sounds and no way to make them your own.

I thought it would be interesting to build an app where users can bring their own sounds (though Voxa ships with some ready to go too). There were other apps on the market already, but I knew I could offer something different: a more modern, flexible experience built on current Android best practices.

03 Main technical decisions

Audio engine: ExoPlayer (Media3)

MediaPlayer is adequate for simple use but offers no fine-grained control over playback state, volume ramping, or looping behaviour. I built the playback layer on top of ExoPlayer (Media3), which provides a rich Player.Listener callback surface, predictable seek semantics, and first-class support for gapless looping. Each sound owns a single PlayerHandle; volume fade-in and fade-out are implemented as smooth interpolated ramps.

UI: 100% Jetpack Compose with Material 3

There are no XML layouts in the project. Every screen is a Composable backed by a ViewModel that exposes StateFlow for one-off events such as showing a Dialog and SharedFlow for things like UI state. UI state is collected with collectAsStateWithLifecycle(), which automatically stops collection when the app is backgrounded and avoids unnecessary recomposition. Navigation is handled by Navigation Compose with type-safe argument passing.

Dependency injection: Hilt with scoped coroutines

All ViewModels, repositories, and services are injected via Hilt. One detail worth calling out: rather than pulling Dispatchers directly, I defined custom @Qualifier-annotated CoroutineScope bindings (@MainScope, @ApplicationScope) that are provided as singletons. This makes dispatcher behaviour explicit in the dependency graph and easy to replace in tests.

Persistence: Room + DataStore

Sound metadata, soundboard configuration, and all settings (UI customisation, playback defaults, sync settings) live in a Room database with seven entities and a @Relation-based SoundboardWithSounds join type, with DAOs returning Flow<T> so the UI reacts to changes automatically. Subscription purchase state is cached in a separate DataStore Preferences store so it survives process death without the need to go through the billing flow again.

Reactive state throughout

The data layer uses a mix of reactive and one-shot patterns. DAOs return Flow<T> for queries that need to stay live - such as soundboard lists and sound metadata - but use suspend fun returning plain types for one-off lookups like getStarterBoard(): Soundboard? and getSoundIdsInOrder(): List<Long>. Repositories transform and combine the streams they receive. ViewModels convert live flows to StateFlow with stateIn(SharingStarted.WhileSubscribed(5_000)) so the upstream query is cancelled five seconds after the last subscriber drops - surviving configuration changes but not prolonged backgrounding.

Backup and restore

Voxa uses Android’s built-in Full Backup API to back up metadata to the user’s Google account automatically, with no server infrastructure or authentication required. The backup configuration (backup_rules.xml) includes the Room database and the DataStore preferences file. Audio files are deliberately excluded. Backing up audio would push most users well past Android’s 25 MB per-app cloud backup quota, and the files will almost certainly still be on the device anyway.

Restore is the more interesting problem. When Android restores an app’s data onto a new or factory-reset device, it restores the database and settings — but the audio files are gone. DetectMissingAudioUseCase runs at startup and checks whether any imported sounds reference a file path that no longer exists. If any are missing, RestoreAudioUseCase runs a two-pass recovery:

The same SHA-256 fingerprint that drives restore also powers deduplication during import: ImportAudioUseCase hashes incoming files before copying them, and if a matching hash already exists in the database, it reuses the existing Sound entity rather than writing a duplicate. Adding the same audio clip to five soundboards costs one copy on disk, not five.

Device-to-device transfers use a separate data_extraction_rules.xml configuration that includes the sounds directory, so in that case files transfer intact and the recovery passes are skipped entirely.

04 Accessibility

Screen-reader-safe status messages

Standard Android Toast messages aren’t reliably announced by TalkBack. Voxa replaces every one with a rememberAccessibleMessenger() composable that shows a Material Snackbar instead. The SnackbarHostState is provided once, above the entire nav host in MainActivity, via a CompositionLocal - so any screen, dialog, or bottom sheet can surface a message without needing its own host, and with no ancestor host (e.g. a Compose preview) the call simply does nothing rather than crashing.

State conveyed beyond colour and icon

Each pad on the soundboard grid communicates its state visually - colour changes when playing, a loop icon flashes, a queue badge shows position. All of that is mirrored to TalkBack through Modifier.semantics { stateDescription = … }, computed per pad from playback state: “Playing”, “Playing, looping”, “Queued, position 2”, “Sound file missing”, or “Selected for deletion” while in bulk-delete mode. Because it’s a stateDescription rather than part of the content description, TalkBack announces just the change on recomposition instead of re-reading the pad’s whole label every time playback state flips.

Collapsing redundant announcements

Compose’s ListItem exposes its icon and headline text as separate semantic nodes by default, which means TalkBack reads a row like “Stop icon… Stop all” as two announcements for one tappable row. Every menu row in the settings and soundboard-options bottom sheets uses Modifier.clearAndSetSemantics { contentDescription = …; onClick(action = …) } to merge them into a single focusable node with one label and a registered click action. The same pattern collapses the “now playing” badge on each soundboard row - an icon plus a number - into one announcement (“3 sounds playing”, via a plurals resource) rather than reading the glyph and the digit separately.

Everyday coverage

Beyond the bespoke cases, every custom clickable/ combinedClickable modifier throughout the pad grid, list rows, and reorder controls is given role = Role.Button so TalkBack announces the correct affordance rather than a generic “double-tap to activate”. Icons are audited one by one: purely decorative ones (checkmarks next to already-labelled text, lightbulb tips) get contentDescription = null so TalkBack skips them, while meaningful ones - the drag handle, missing-file warning, mic pause/resume - carry real, localised descriptions. Volume and fade sliders get their setting name attached via Modifier.semantics { contentDescription = … } so TalkBack announces “Fade duration” rather than just “Slider”.

05 Screenshots

Voxa home screen listing four soundboards
Soundboards - idle
Soundboard list with a sound actively playing
Soundboards - playing
Pad grid in idle state
Pad grid - idle
Pad grid with Applause and Cheering & Clapping actively playing
Pad grid - playing
Per-pad settings showing volume, fade, and playback mode
Per-pad settings
Settings bottom sheet over the soundboard list
Settings modal

Demo video

Demo

06 Architecture - key components

ui/Compose UI, navigation, ViewModels
soundboardlist/Soundboard list - add, rename, reorder soundboard/Adaptive pad grid, playback controls appsettings/Settings menu, navigation hub globalplaybacksettings/Fade, loop, and volume defaults generaluisettings/Dark mode, accent colour, keep-alive persoundplaybacksettings/Per-pad overrides, sample import
domain/Use-case layer
ImportAudioUseCaseValidate, copy, and register a sample SetupStarterBoardUseCaseFirst-run soundboard scaffolding
data/Room DB + DataStore + repositories
SoundRepositorySound CRUD, Flow-backed queries SoundboardRepositorySoundboard CRUD, join queries GlobalPlaybackSettingsRepositoryFade, loop, volume, tap-action defaults GeneralUiSettingsRepositoryUI preferences (dark mode, colour scheme)
audio/ExoPlayer wrapper
ExoPlayerSoundPlayerPlayerHandle per sound, fade ramps SoundPlaybackStatePosition, progress, loop state per sound

The audio layer is deliberately isolated from the ViewModel layer: the SoundPlayer interface is bound in a Hilt module, so the ViewModel never imports ExoPlayer directly. Parameter changes (gain, fade duration, repeat mode) are dispatched through SoundPlayer interface calls; playback state flows back up as StateFlow the ViewModel collects and merges into its UiState.

07 Testing approach

08 Honest retrospective

Picking a well-supported stack meant most implementation work was straightforward - ExoPlayer’s API is extensive and well-documented, Hilt removes all of the boilerplate that would otherwise be needed, and Room’s Flow integration maps cleanly onto Compose’s state model.

The parts that required the most care:

09 Ideas for future features