I've been building a tinnitus relief app in Flutter for about two months. The user matches the pitch of their ringing, and every sound is then built on the device with a notch filter at that pitch: noise colours synthesised in Dart, plus 30 real recordings (rain, fan, ocean...) that are decoded, notched in an isolate and layered, one just_audio player per layer. A few things that cost me time and might save you some:
- Render in an isolate, cache by a quantised key. Each loop is built with compute from a params object whose key rounds every value (pitch, notch width, colour, level), so near-identical settings hit the same file. The WAVs live in the temp dir, pruned to the newest 32.
- Play files, not streams. A custom StreamAudioSource goes through just_audio's localhost proxy, and Android blocks that as cleartext HTTP. Writing a WAV and playing the file avoids the whole thing.
- Never await play(). just_audio's play() future completes when playback stops, not when it starts. Awaiting it froze one of my screens. Start it with unawaited(...) and carry on.
- Don't take audio focus. Masking has to sit under YouTube, podcasts and calls. With audio_session set to never request focus and never pause on interruptions, it does. I also had to swap just_audio_background for audio_service, because the former only handles one player and I have up to six.
- "Is it playing?" is not "is the user listening?". I added a button that plays the user's matched tone for a few seconds through the same engine, and my listening-time counter checked player.playing, so it counted the preview as listening. A flag for "this is a UI sound" fixed it.
- Test DSP with Goertzel. The tests synthesise a loop and compare the energy at the notch frequency against its neighbours with a Goertzel filter, averaged over a few bins because noise is noisy. A notch you can't hear is still a notch you can assert on.
- One pure function decides what's locked. Paid features are stripped in a single function that turns UI state into synth params, and it's unit-tested, so a stale toggle can never reach the audio.
- Set preferred-supported-locales. When the phone's language isn't one you ship, Flutter falls back to the first supported locale, and gen-l10n lists them alphabetically. Mine has Arabic, so a Dutch user opened the app in Arabic. One line in l10n.yaml puts English first.
- Test at 1.3x text with display zoom. My setup screens were fixed columns that fit on every phone I tried. On a Galaxy S20 FE with large text, the Start button sat below the screen and the page couldn't scroll to it. I found out from a 1-star review that said "froze on part 1/7". Every step now scrolls its content and pins its buttons.
- First sound matters more than features. Half of first listens ended inside a minute. Setup ended on a music demo, then the player started bare pink noise on its own within two seconds, and people heard a hiss and pressed pause. Now nothing plays until they tap, the first sound is rain over soft noise, and it fades in over eight seconds.
- Fixed headroom. Everything is normalised to a 0.6 peak. People listen for hours on headphones, so loud is not a feature.
Happy to go deeper on any of it, the DSP side especially. If anyone wants to see it running, the app is Sylence on Play, and I can drop the link in a comment.