A sing-along breath practice app. You pick a familiar song, sing along, and hold the sustained note at the end of each line. The app listens, measures how long and how steadily you held it, and draws your breath back to you as a mountain ridge.
Promising, not prescribed — this app is breath practice, not a medical device. Nothing here diagnoses, treats, or replaces pulmonary rehabilitation.
Singing asks for the same thing pulmonary rehabilitation trains for: a long, steady, controlled breath out. That is what this app measures. It's built with people living with lung conditions — COPD, interstitial lung disease — in mind, though anyone can use it.
The evidence is real but early, and the app is careful about saying so. The
citations behind the "Why this helps" card live in
lib/core/research/lung_health_research.dart
— the SINFONIA RCT (ERS Congress 2025, n=101, measuring quality of life rather
than spirometry) and a Cochrane review (McNamara et al., 2017) that rates the
evidence "low to very low". That same file is the single source for both the
in-app card and the AI coach's system prompt, so the app cannot say something
more confident in one place than in the other.
| Term | What it means |
|---|---|
| Take | One recorded session of one song. Holds a per-phrase result for each line, plus the score. |
| Phrase | One singable line and the sustained note at the end of it. |
| Breath Score | The hero metric, 0–100. Half how close your longest hold came to your personal baseline, half how steady your breath was across the phrases you attempted. No third term, no hidden weighting. |
| Ridge | The breath visualization. "Terrain, not a signal trace" — sky, receding ranges, and one lit contour that is your actual breath. |
| Sigil | A generative seal drawn from a take's own numbers. Same input, same seal, forever — the image is the data, not a decoration of it. Hold length sets size, steadiness sets edge quality, phrase count sets ring count, a personal record adds one gold mark. |
| Baseline (MPT) | Your personal maximum phonation time — the number the score is measured against. |
- Flutter 3.44.4, Dart SDK
^3.12.2 - Android SDK 23+ or iOS 13.0+
- A physical device is strongly recommended — simulators have unreliable microphones
.fvmrc is used to pin the flutter version but no .fvm/ directory is checked in, so
fvm is declared rather than configured. Use plain flutter commands, or run
fvm install first if you want the pin enforced.
flutter pub get # the lockfile predates some of the dependencies
flutter test
flutter run| Flag | Default | Effect |
|---|---|---|
--dart-define=USE_REAL_API=true |
off | Switches every repository from mocks to HTTP |
--dart-define=API_BASE_URL=https://… |
empty | Where the real API lives |
--dart-define=OPENAI_API_KEY=sk-… |
unset | Enables the AI reflection on the results screen |
flutter run --dart-define=USE_REAL_API=true --dart-define=API_BASE_URL=https://api.example.comMocks are on by default rather than off, on purpose: an unset or misspelled build flag should leave the app working rather than blanking every screen.
Without it, the results screen falls back to a locally store sentence. No network call is made when the key is absent.
cp lib/secrets.example.dart lib/secrets.dart # gitignorePaste your key there, or leave the template alone and pass
--dart-define=OPENAI_API_KEY=… at build time. The key is never logged and
never committed.
Required for real use, and requested during onboarding.
Permissions are already declared: RECORD_AUDIO on Android,
NSMicrophoneUsageDescription on iOS.
Audio is analyzed in a rolling window and discarded. Nothing is recorded to disk, and nothing is transmitted.
Layered, with the dependency arrow pointing inward. presentation → domain ←
data; scoring and sigil depend on nothing but dart:math and plain data.
lib/
app/ Composition root (AppDependencies), router, routes
audio/ The only code allowed to touch audio capture
core/ theme/ (design tokens, typography), utils/, research/
data/ api/ (HTTP + errors), local/ (KeyValueStore), mock/, repositories/
domain/ Models and repository interfaces. No Flutter imports.
presentation/ One Cubit + State + Page per feature, plus common/ widgets
scoring/ Breath Score. Pure — no Flutter, no plugins, no I/O.
sigil/ Deterministic generative seals
State management: flutter_bloc Cubits with equatable states. One cubit
per feature, provided at its route rather than at the app root — that is what
disposes the cubit on leaving a screen, which for the sing route is what
releases the microphone. A root-level provider would keep an engine alive behind
a screen nobody is looking at.
Dependency injection: constructor injection from a single composition root
(AppDependencies.bootstrap()). No service locator, no global singletons.
Rules that are load-bearing rather than stylistic:
- No widget calls HTTP or storage directly.
- No widget touches audio — they subscribe to
BreathEngine.frames. core/theme/app_tokens.dartis the only place a color literal exists (the sigil palette documents its own exception).lib/scoring/stays free of Flutter so it can move server-side unchanged, and so the same take always produces the same score.
| Path | Screen |
|---|---|
/ |
Home — greeting, last session, song list |
/welcome |
Onboarding. ?step=microphone skips the intro screens |
/progress |
Trend chart and sigil collection |
/settings |
Profile and preferences |
/sing/:songId |
The singing session |
/take/:takeId |
Results for one take |
The initial route is chosen synchronously from OnboardingFlag.isComplete, so a
returning user goes straight to home with no welcome screen flashing past.
RecordMicSignalSource → FrameBuilder → BreathEngine → Cubit → Ridge
48kHz mono calibration, ~60Hz state paint
2048-sample window hysteresis, BreathFrames
~60 windows/sec voicing
MicSignalSource is the seam: it yields raw MicSamples, never finished
frames, so FrameBuilder owns every threshold. That's what lets a fixture and a
live microphone feed the exact same render path — nothing downstream knows which
it's getting.
Echo cancellation, noise suppression, and automatic gain control are all switched off deliberately. AGC turns a fading note into a steady one, which would silently corrupt the one thing this app measures.
configureSingingAudioSession() runs once in main() before any audio plugin
loads, setting the session to playAndRecord so the backing track can play
while the mic captures. Reconfiguring mid-session used to tear down the mic
stream.
flutter test| File | Covers |
|---|---|
test/scoring/breath_score_test.dart |
Phrase and take scoring, and the wording of describeTake |
test/audio/frame_builder_test.dart |
Calibration, silence, and voicing runs against deterministic fixtures |
test/sigil/sigil_generator_test.dart |
The six sigil parameters, and that a take's seal never changes |
test/widget_test.dart |
Smoke test: boots against mocks and reaches the song list |
The audio fixtures in lib/audio/fixtures/ are synthesized and deterministic
(steady-12s, ragged-7s, fading-9s, short-3s, silence, calibration), which is
what makes the scoring tests meaningful without a microphone.
- Only "Chandelier (local test)" has a backing track. Every other song is mic-only by design.