Development & Learning Plan · v0.1
Hands‑Free Ear Trainer
For an experienced developer new to Kotlin and Android, on Arch Linux, using Android Studio + Claude Code, testing on a daily-driver phone. The app is the training ground — learning Android development properly is a co-equal goal.
Companion to hands-free-ear-trainer-spec-v0.4.md and hands-free-ear-trainer-architecture-v0.4.mermaid.
Decisions locked in (2026‑07‑05)
- Pure Kotlin core module now, KMP conversion later (mechanical when the time comes).
- Android Studio for emulator/debugger/profiler; Gradle + adb CLI for Claude Code sessions.
- Real-device testing from day one (emulator audio is unrepresentative for pitch work).
- Kotlin-level
AudioRecordfor mic input in v1; Oboe/AAudio (C++/NDK) only if latency measurably hurts. TarsosDSP is JVM-only anyway, so it pairs withAudioRecord.
How to use this plan
- Phases are ordered risk-first: the two things that could sink the project — real-time pitch detection of your voice on your phone, and the half-duplex play→listen loop — are proven in Phases 2–4, before any investment in the data model.
- Each phase lists increments — small changes you build, run on the phone, and commit. Roughly one commit per checkbox.
- Each phase has a Learn section. Read just-in-time, not all up front — but do read it.
- Exit test = how you know the phase is done. Don't move on without it.
- Working rhythm per increment: skim the Learn link → implement the smallest version →
adb installand try it on the phone → commit → ask Claude to explain anything that felt like magic.
The Gradle files, annotated
ReferenceWritten together while skimming the project's actual build files, following how Gradle resolves them: root settings → root build script → module build script → version catalog.
settings.gradle.kts — the project graph
include(":app") is what makes app/ a module (Gradle calls it a "subproject") — a directory with its own build.gradle.kts that Gradle treats as a semi-independent build unit: its own dependencies, its own compiled output, but able to depend on other modules in the same build. Right now there's exactly one, :app. When Phase 4 adds the pure-Kotlin core/ module, include(":core") gets added here and nowhere else needs to know about it structurally.
rootProject.name is just the display name for the whole build (Android Studio's project tree, build scans) — unrelated to applicationId. The dependencyResolutionManagement { repositoriesMode.set(FAIL_ON_PROJECT_REPOS) } block is a guardrail: it forces every module to declare dependency repositories centrally here rather than each module picking its own.
Root build.gradle.kts — plugin versions, applied nowhere yet
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}
apply false is the key idiom: this resolves which version of the Android Gradle Plugin and the Kotlin Compose plugin the whole build uses, without applying either to the root project itself (the root project has no source code — it's just a container). Each module then applies whichever plugins it actually needs. Phase 4's core/ module will use this differently: it'll apply java-library plus the plain Kotlin JVM plugin, but never com.android.application — core has no Android dependencies at all.
app/build.gradle.kts — the module that actually builds something
Here the same plugins get actually applied (no apply false) — this module produces an APK.
namespace vs applicationId — right now they're identical (dev.nkini.handsfree.earandvoicetraining), which is why it's easy to conflate them, but they do two different jobs:
android {
namespace = "dev.nkini.handsfree.earandvoicetraining"
defaultConfig {
applicationId = "dev.nkini.handsfree.earandvoicetraining"
minSdk = 26
targetSdk = 36
}
}
namespace is a compile-time thing: it's the Kotlin/Java package your generated R class (and BuildConfig) lives in — it only matters to the compiler. applicationId is the app's actual identity on the device and Play Store — it's what adb shell pm list packages shows, and it's literally what we used to launch this app: adb shell am start -n dev.nkini.handsfree.earandvoicetraining/.MainActivity — that string before the / is the applicationId, not the namespace. They diverge in practice mainly for build variants: a common pattern is an applicationId suffixed .debug so debug and release installs can coexist on one device, while namespace stays the same since the source doesn't change.
compileSdk / minSdk / targetSdk are three different things people conflate: compileSdk is which SDK version's APIs you're allowed to call at compile time (36); minSdk is the oldest OS version the APK will even install on (26 — chosen because AudioSource.UNPROCESSED needs API 24+, and modern TTS/foreground-service behavior wants recent APIs); targetSdk tells the OS "I've been tested against this version's behavior changes, opt me into them" (also 36).
buildFeatures { compose = true } turns on the Compose compiler integration for this module — without it, @Composable wouldn't compile.
gradle/libs.versions.toml — the version catalog
A single TOML file with three tables: [versions], [libraries], [plugins]. Gradle auto-generates a type-safe accessor object called libs from this file, which is why app/build.gradle.kts can write libs.androidx.compose.material3 instead of a raw Maven coordinate string — Android Studio autocompletes it and a typo becomes a compile error instead of a silent miss. The point of centralizing versions in one file: once the core/ module exists, both app/ and core/ reference the same libs.xxx entry and get the same version — no drift between modules.
One thing worth noticing:
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
… has no version. That's because of app/build.gradle.kts: implementation(platform(libs.androidx.compose.bom)). A BOM ("Bill of Materials") doesn't add code — it just pins consistent versions for a whole family of libraries. Every androidx.compose.* artifact in this project inherits its version from composeBom = "2026.02.01" rather than needing its own pin, which is why the Compose libraries never drift out of sync with each other.
gradle.properties
Mostly build-machine tuning, not app config: JVM heap for the Gradle daemon, and org.gradle.configuration-cache=true — this is why the second assembleDebug run said "Configuration cache entry reused" and finished in seconds instead of ~1.5 minutes.
Audio & pitch detection, annotated
ReferenceAdded 2026‑07‑06, after the first YIN session put unfamiliar words in the log — confidence, scoop, stable segment, "RMS above −48 dBFS." Each concept below is pinned to a number actually measured in this project's own room.
Sound as numbers: samples, PCM, buffers
A microphone turns air pressure into voltage; the phone measures ("samples") that voltage 48,000 times a second and stores each measurement as a 16‑bit integer (−32,768…32,767). That's PCM — pulse-code modulation — and it's the only thing audio code ever sees: arrays of integers. MicCapture reads them 2048 at a time, so one buffer spans 2048 ÷ 48,000 ≈ 43 ms — the app's basic "tick" of hearing. Everything downstream (RMS, YIN, soon PitchFrame) is a per-buffer computation.
Loudness: RMS and dBFS
RMS (root mean square) is the standard one-number answer to "how loud is this buffer": square every sample (all positive, big swings emphasized), average, square-root. It measures the signal's energy, which tracks perceived loudness much better than the single largest sample would.
dBFS (decibels relative to full scale) puts that on a log scale where 0 dBFS is the loudest representable signal and every −20 dB is a 10× smaller amplitude. Logarithmic because hearing is: quiet room to shout spans a factor of ~10,000 in amplitude — unreadable linearly, a tidy −80…0 in dB. This project's measured landmarks so far:
−180 dBFS digital zero (the all-zero first buffer)
−63 dBFS this room's noise floor
−52 dBFS the ~120 Hz room hum (see Confidence)
−45…−30 singing / speaking at the phone
0 dBFS rail-to-rail; nothing should get here
So "gate on RMS above −48 dBFS" means: only consider a buffer as possibly-an-attempt if its energy is in voice territory, comfortably above both hum and floor.
Pitch: fundamentals and harmonics
A sung note is a periodic waveform: the same shape repeating f₀ times per second. f₀ is the fundamental frequency, and it's what we hear as the pitch. But a voice is not a sine wave — the vocal folds put energy at f₀ and at every integer multiple (2f₀, 3f₀, …), the harmonics; their relative strengths are the timbre, the reason a voice on A3 sounds nothing like a sine at 220 Hz. Pitch detection is therefore not "find the strongest frequency" (a harmonic often beats the fundamental) — it's "find the repetition period."
Notes, MIDI numbers, cents
Equal temperament is logarithmic in frequency: each semitone multiplies by 2^(1/12), each octave doubles. MIDI numbers the semitones (C4 = 60, A4 = 440 Hz = 69), so frequency→note is one log₂: n = 69 + 12·log₂(f/440) — that's NoteMath.frequencyToMidi, returning a fractional MIDI number whose fraction × 100 is the offset in cents (hundredths of a semitone): 442 Hz ⇒ 69.08 ⇒ "A4 +8¢". ±50¢ covers one note's entire territory — why the tuner bar will run −50…+50 and why judge tolerance is expressed in cents.
How YIN finds the period (and when it errs)
YIN slides the buffer against a delayed copy of itself and asks, for each candidate delay τ: how different is the signal from itself τ samples later? For a periodic signal that difference collapses toward 0 when τ equals one true period. YIN takes the first τ whose normalized difference dips below a threshold, then refines it. Two consequences worth carrying around:
Octave errors. A signal with period T is also self-similar at 2T, 3T… so a detector can lock onto a multiple and report an octave (or more) low. Already observed: the ~120 Hz hum occasionally read as ~62 Hz "B1". (The YIN paper's §2 — already in Phase 3's Learn list — is the read on why YIN errs this way less than plain autocorrelation.)
The window floor. Seeing a dip at delay τ needs ~2τ samples of window. 2048 samples @ 48 kHz caps τ at 1024 ⇒ nothing below ~47 Hz is detectable, with reliability degrading on approach. Fine for singing (~80 Hz up); it's also why readings below the singable range deserve distrust.
Confidence — what it is and, crucially, isn't
YIN's confidence (TarsosDSP calls it probability) reflects how deep that self-similarity dip was — i.e. how cleanly periodic this buffer is. It is not "how likely this is a voice," and not "how likely this is the right note." The first YIN session made that concrete: a ~120 Hz room hum (characteristically 2× the 60 Hz mains — a transformer or appliance somewhere) registered at confidence 0.80–0.98 throughout every "silent" gap, because hum is beautifully periodic. A fridge can be more confident than you. Hence the voicing gate must be a conjunction — RMS in voice territory AND high confidence AND sustained across consecutive frames — no single signal suffices.
Voicing
Borrowed from speech processing: a frame is voiced when the vocal folds are vibrating (singing, vowels), unvoiced otherwise (silence, breath, "s"/"sh" noise). The upcoming PitchFrame carries a voicing flag per frame, and the spec defines attempt detection on voicing only — "did they start singing?" — never on proximity to the target, so a wrong note still counts as an attempt and earns feedback rather than silence.
The shape of a sung note
What the log showed on a matched note: first the attack (the opening tens of milliseconds while the sound settles — in general audio, the transient), often shaped as a scoop — starting under the pitch and sliding up into it (measured: A#2 → B2 → C3 over ~500 ms); then the sustain, wobbling ±10–30¢ (a trained voice adds deliberate periodic wobble: vibrato). Stable-segment scoring (Phase 6) is the judge built for that shape: discard the first ~150 ms, take the median pitch of the sustained remainder — median because it shrugs off single-frame outliers like the session's one stray 69 Hz blip — and compare that to the target within a cents tolerance. Judge the note, not the journey into it.
Resources
- Seeing Circles, Sines, and Signals (Jack Schaedler) — a beautiful interactive DSP primer: sampling, sine waves, phase, aliasing. The single best starting point; an hour well spent.
- But what is the Fourier transform? (3Blue1Brown) — the frequency-domain mental model, visually. YIN itself works in the time domain, but this is foundational for everything else in audio.
- Digital Signals Theory (Brian McFee) — free, modern, code-first textbook, written for programmers entering audio; the place to go when you want the real thing.
- The YIN paper §2, already in Phase 3's Learn list — approachable now that the vocabulary above is in place.
- Music and Computers (Burk, Polansky, Repetto, Roberts, Rockmore) — the musician-first companion; chapters 1–2 for the physics-of-sound side.