Skip to content

Louder sounds (-20 LUFS) and a sleep timer for Ambient mode - #14

Merged
jaimebg merged 31 commits into
mainfrom
feat/loudness-and-sleep-timer
Sep 16, 2026
Merged

jaimebg merged 31 commits into
mainfrom
feat/loudness-and-sleep-timer

Conversation

@jaimebg

@jaimebg jaimebg commented Sep 16, 2026

Copy link
Copy Markdown
Owner

Closes #12, closes #10. Two independent changes in three commits.

#12 Low volume: re-master at -20 LUFS

Every shipped loop and chime sat at -30 LUFS integrated, the level chosen in August because cave and fireplace could not go up as pure gain. That is 14-16 dB under what music apps play at (-14 to -16 LUFS), which is exactly the report from the Nokia X20.

File Before After Peak after Limited by
cave -30.0 -20.0 -2.2 dBFS 7.2 dB on 5 drips (0.014% of samples)
fireplace -29.9 -19.9 -1.1 dBFS 7.8 dB on 2 crackles (0.001%)
birds -30.4 -20.4 -2.0 dBFS 1.3 dB on one peak
stream -30.0 -20.0 -2.4 dBFS 0.7 dB on one peak
forest -30.1 -20.1 -2.5 dBFS 0.1 dB
the other 9 -30 -20 -3.4 to -12.8 nothing

tools/process-audio.sh now applies the gain, then a lookahead limiter (alimiter, 5 ms attack, 50 ms release, latency-compensated) at -2.5 dB sample peak, then runs the existing two-pass loudnorm which still refuses anything but linear mode. A 0.5 dB margin was not enough: fireplace's crackles carry inter-sample peaks 1.05 dB above their sample peak and pushed loudnorm into dynamic mode, so the margin is 1.5 dB. All 14 files pass the loop-seam and loudness checks. The noise beds and success chime were regenerated from their scripts; the five Freesound cuts came from the cached originals.

No code change. Stored master volume is untouched, so people who maxed everything simply hear the fix. This is about twice as loud perceptually and will be noticed on update.

#10 Sleep timer in Ambient mode

A "Stop after" chip row (Off, 15, 30, 45, 60, 90 min) sits under the dial where Timer mode shows the focus presets, persisted in preferences. Play starts the countdown on the same TimerRepository the plans use; the dial counts it down instead of showing ∞; pause and play pause and resume it. On completion with no plan in progress the ViewModel fades the audio out through the existing 3 s stop and resets the timer: no chime, no haptic, no session record. Picking a new length restarts a running countdown from now; switching to Timer mode drops it. Three new strings in all 47 locales.

Verification

  • ./gradlew test lint assembleDebug: green. 10 new tests (1 datastore, 8 view model, 5 Compose/Robolectric). The completion test was checked by mutation: removing the hook fails exactly that test.
  • tools/check-translations.sh: all keys translated.
  • Store shots regenerated from :store-assets; the timer and settings shots were still pre-Plan and are refreshed in the same pass. README strip rebuilt.
  • Not done: an on-device run of the sleep timer. No emulator was up. Worth a bedtime trial before release: the countdown runs in-process like the focus timer and relies on playback keeping the CPU awake.
  • Not done: a changelogs/4.txt entry; that belongs to the release cut.

Switching to Ambient mid-break stops the runner, and the progress collector then
handed the slot back to FOCUS without telling the service anything. The break
tracks kept playing under a gradient, a particle field and a mix bar that had
all gone back to the focus mix. The collector now stops the audio when the slot
it is leaving was BREAK.

The same collector is also where a ViewModel recreated over a plan already in
progress learns its step, so it recomputes the audible slot from that step —
state only, because the audio is the service's and is already correct.
The slot switch is only drawn while break sound is on. A picker left on the
break tab therefore went on editing a mix that could no longer play, with
nothing on screen saying so and no switch left to move it back.
ModifierParameter: the modifier has to be the first optional parameter, so a
caller can pass it positionally after the required ones. Every call site names
its arguments, so only the declarations move. SoundBottomSheet takes no
modifier at all and is left alone.
- pushMix took a mix parameter no caller ever passed; it reads the audible mix
  itself now.
- selectedMinutes falls back to the default focus length instead of first()-ing
  a plan that a future decoder could hand back empty.
- planForStart takes 25 and 50 from the presets that define them.
- editDraft buzzes only when there is a draft to edit.
- PlanCodec.encode matches both step kinds, so a third one would not silently
  encode as a break.
English used "Pause" for the break row of the plan editor and for its two
stepper descriptions, so the translators of those three keys reached for the
media-playback word: Arabic said إيقاف مؤقت and Chinese 暂停 — "paused", as in a
stopped track — while the same screen's timer label and picker tab already said
استراحة and 休息. The keys keep their names; each locale's three values now reuse
the break word that file already uses for state_break_time, timer_step_break
and picker_slot_break. Twenty-two locales had translated around the English and
needed no change.
…ontroller changes state

A stop() right before a play() (the StepCompleted -> StepStarted transition)
starts a fade-out that would eventually call controller.stop(), but play()
cancels that fade job before it gets there. The controller never actually
stops, so onIsPlayingChanged never fires and _isPlaying stays false while
audio keeps playing. play() now sets _isPlaying true immediately once it has
a controller, mirroring how pause()/stop() already update it eagerly; the
listener remains the source of truth for later changes.
…le at a normal phone volume

Every loop and chime shipped at -30 LUFS, 14-16 dB under what music apps
play at, and a user reported needing the phone near maximum to hear the
app (#12). The target was set by cave and fireplace, whose peaks sit 25 dB
above their loudness on a handful of drips and crackles.

The pipeline now applies the gain first and then a lookahead limiter that
only touches samples crossing the ceiling: about 7 dB on those few
transients, nothing on the other twelve files. loudnorm still runs after
it and still refuses anything but linear mode, so nothing pumps. The two
noise beds and the success chime were regenerated from their scripts, the
Freesound cuts came from the cached originals, and all 14 files pass the
loop and loudness checks.

Closes #12
Ambient mode played until someone paused it, which is the wrong shape for
falling asleep to (#10). A "Stop after" row now sits under the dial where
Timer mode shows the focus presets: Off, 15, 30, 45, 60 or 90 minutes,
persisted so a bedtime choice sticks.

Pressing play starts the countdown on the same TimerRepository the plans
use; the dial counts it down where it showed the infinity sign, and pause
and play pause and resume it. When it completes with no plan behind it
the ViewModel fades the audio out through the existing stop and resets
the timer. No chime, no haptic, no session record: the listener is
asleep. Choosing a new length restarts a running countdown from now;
switching to Timer mode drops it, since nothing else would.

Three new strings in all 47 locales.

Closes #10
The mix scene renders in Ambient mode, so the new "Stop after" row is in
it. The timer and settings shots were still from before the Plan preset
and the break-sound toggle; they are regenerated from the same pass.
@jaimebg
jaimebg merged commit 3893e34 into main Sep 16, 2026
3 checks passed
@jaimebg
jaimebg deleted the feat/loudness-and-sleep-timer branch September 16, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Relatively low sound level Timer in ambient mode

1 participant