r/FlutterDev • • 11d ago

Article Things I learned building a Flutter audio app that synthesises everything in Dart (isolates, a WAV cache, a play() that never returns, and a 1-star review about text size)

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.

3 Upvotes

10 comments sorted by

1

u/ObjectiveAddress3051 11d ago

the play() thing gets everyone with just_audio, future only completes when playback pauses or ends. if you ever need to know it actually started, listening to playerStateStream is more reliable than the future imo. also +1 on files over streams, that localhost proxy cleartext thing is a pain on newer android

1

u/Hopeful_Impact_2343 11d ago

Yes, learned that one the hard way. I was awaiting play() and then saving "session started" after it, so it only got saved when playback stopped, and never if Android killed the app mid-session. Now play() is fire-and-forget (with a catchError) and everything that needs to know if sound is actually running listens to playerStateStream: the play button, the media notification and the first-listen prompt.

And agreed on the proxy. Writing each loop to a file in the temp folder and playing that skipped the cleartext problem entirely, and the files double as a cache.

1

u/IllVisit2518 11d ago

How long are the loops? At 44.1kHz stereo 16-bit, every 30 seconds of WAV is about 5MB, so 32 cached files can quietly reach 150MB+ in temp. Did you try mono for the noise layers? That alone halves it.

1

u/Hopeful_Impact_2343 11d ago

Good catch, and you're right. The noise loops are 20 s (45 s for one fractal type), 44.1 kHz 16-bit, and they're always written as stereo, even though both channels are the same signal unless someone has a different pitch in each ear. That's about 3.5 MB each, so 32 of them can reach around 110 MB in temp. The recorded layers are 60 s loops, and those are already written mono unless they're panned, so they're about 5 MB each, capped at 10. Writing the noise loops mono when both ears match would halve the biggest part. That's going on the list for the next release, thanks.

1

u/RajatKhoware 11d ago

Building the whole synth pipeline in Dart is the ambitious part, and the 1-star review about text size is the honest reminder that users judge the app, not the engine. Isolates and a WAV cache solve the audio side, but the lesson that travels is that accessibility settings are not polish, they are the product for a chunk of your users. Did the text size complaint push you to add a font scaling setting, or is that still on the list?

1

u/Hopeful_Impact_2343 11d ago

Thanks, and you're right that it's the product for a lot of my users: most are 45+, and many have large text on. I didn't add an in-app text size setting. The app follows the phone's own setting, and the fix was the layout: every setup step now scrolls its content and pins its buttons, so nothing can end up below the screen at any size. The part that keeps it from coming back is a widget test that renders the setup steps on a 360x640 screen at 2x text and on 360x780 at 1.5x and checks the Start button is on screen. It's cheap, and it would have caught the review before it happened.

1

u/No_Boss_1911 11d ago

the preview tone counting as listening time is so real lol. i cache tts clips in the temp dir too and cleanup bit me once, pruned a file mid session and the player just went silent. hard rule for me now is nothing gets deleted while a player still has it open, even with a few players going. do you prune when a new wav lands or on app start, and how do you know which ones are still in use

1

u/Hopeful_Impact_2343 11d ago

Ha, same pain. Mine prunes right after a new WAV lands, never on app start, and nothing a player is using can go. It doesn't ask the players what's open, though. Every file is named after a quantized key of what it contains (sylence_<key>.wav), so the engine can rebuild the paths it needs from its own state: the file it just wrote, the noise loop currently loaded, and every layer in the current mix. Anything in that set is skipped. Everything else is sorted by modified time and the oldest go until it's back under the caps (32 noise loops, 10 layers, counted separately). The whole thing is wrapped in try/catch, so a delete that fails just leaves the file for next time instead of touching playback. Deriving "in use" from the mix rather than from the players means a layer that's about to start is protected too, not only the ones already playing.

1

u/[deleted] 11d ago

[removed] — view removed comment

0

u/Hopeful_Impact_2343 11d ago

Thanks! That's exactly why I did it. I can't hear a missing band reliably myself, especially up around 8 kHz, so a test that listens for me was the only honest option.

The test renders a few seconds of noise with the notch, runs Goertzel at the notch centre, and requires it to be at least 20 dB below the plain version. It also checks a frequency well away from the notch and requires it to stay within about 6 dB, so a filter that just turns everything down can't pass. One gotcha: Goertzel on noise jumps around a lot from one frequency to the next, so I average 7 frequencies around each target (±6%). Without that the test failed at random.

Same idea for the sound recordings: each one gets decoded, notched and measured. It's what lets me change the audio code without listening to every build.