Skip to content

Repository files navigation

Melodair

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.


What it's for

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.

Vocabulary

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.

Running it

Prerequisites

  • 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.

First run

flutter pub get      # the lockfile predates some of the dependencies
flutter test
flutter run

Build flags

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.com

Mocks 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.

The OpenAI key

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   # gitignore

Paste 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.

Microphone

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.


For developers

Architecture

Layered, with the dependency arrow pointing inward. presentationdomaindata; 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.dart is 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.

Routes

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.

Audio pipeline

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.

Testing

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.

Known rough edges

  • Only "Chandelier (local test)" has a backing track. Every other song is mic-only by design.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages