fix(sleep): звук сна вплывает в конце таймера, а не в первые 90 секунд

Было: при таймере со звуком радио кроссфейдилось в шум в НАЧАЛЕ (CROSSFADE_MS=90с),
и для 15-мин таймера уже через ~1.5 мин играл полный белый шум всё оставшееся время.

Стало: радио играет почти весь таймер; в последние SOUND_OUTRO_MS (3 мин, но не
больше половины таймера) включается звук сна — радио кроссфейдится в шум, шум держится,
в самом конце затухает в тишину. Генератор шума стартует лениво (только в аутро, не
молотит весь таймер). Засыпаешь под радио, а не под резкий шум сразу.
This commit is contained in:
nk
2026-06-06 18:21:04 +03:00
parent 84c2b33473
commit d9acc0efb4

View File

@@ -86,8 +86,9 @@ class PlayerController @Inject constructor(
private companion object { private companion object {
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
const val CROSSFADE_MS = 90_000L // переход радио → звук для сна const val CROSSFADE_MS = 90_000L // переход радио → звук для сна (внутри аутро)
const val SOUND_VOL = 0.6f // комфортная громкость шума const val SOUND_VOL = 0.6f // комфортная громкость шума
const val SOUND_OUTRO_MS = 180_000L // финальное окно со звуком сна (последние ~3 мин)
} }
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -253,8 +254,10 @@ class PlayerController @Inject constructor(
/** /**
* Запустить таймер сна на [durationMs] мс. * Запустить таймер сна на [durationMs] мс.
* Без [sound]: в последние FADE_MS радио экспоненциально затухает, затем пауза. * Без [sound]: в последние FADE_MS радио экспоненциально затухает, затем пауза.
* Со [sound] («Smart Sleep Fade»): в начале радио кроссфейдится в звук для сна * Со [sound]: радио играет почти весь таймер; в последние SOUND_OUTRO_MS (не больше
* (радио ↓, шум ↑), затем шум играет, в конце затухает — как в спеке. * половины таймера) включается звук для сна — радио кроссфейдится в шум (радио ↓,
* шум ↑), шум держится, в самом конце затухает в тишину. Засыпаешь под радио, а не
* под резкий белый шум в первые же полторы минуты.
*/ */
fun startSleepTimer(durationMs: Long, sound: SleepSound? = null) { fun startSleepTimer(durationMs: Long, sound: SleepSound? = null) {
sleepJob?.cancel() sleepJob?.cancel()
@@ -263,41 +266,52 @@ class PlayerController @Inject constructor(
sleepJob = timerScope.launch { sleepJob = timerScope.launch {
val start = SystemClock.elapsedRealtime() val start = SystemClock.elapsedRealtime()
val end = start + durationMs val end = start + durationMs
// Кроссфейд не длиннее трети таймера (для коротких интервалов). // Финальное окно со звуком — не длиннее половины таймера (для коротких).
val crossfade = if (sound != null) CROSSFADE_MS.coerceAtMost(durationMs / 3) else 0L val outro = if (sound != null) SOUND_OUTRO_MS.coerceAtMost(durationMs / 2) else 0L
if (sound != null) sleepSoundPlayer.start(sound) // Кроссфейд радио→шум занимает первую половину аутро.
val crossfade = CROSSFADE_MS.coerceAtMost(outro / 2).coerceAtLeast(1L)
var soundStarted = false
while (true) { while (true) {
val now = SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val remaining = end - now val remaining = end - now
if (remaining <= 0L) break if (remaining <= 0L) break
_sleepRemainingMs.value = remaining _sleepRemainingMs.value = remaining
if (sound != null) { when {
val elapsed = now - start sound != null && remaining <= outro -> {
when { // Генератор шума стартуем лениво — только в аутро, не весь таймер.
elapsed < crossfade -> { if (!soundStarted) {
// Кроссфейд: радио вниз, шум вверх. sleepSoundPlayer.start(sound)
val f = elapsed.toFloat() / crossfade soundStarted = true
exoPlayer.volume = (1f - f).coerceIn(0f, 1f)
sleepSoundPlayer.setVolume(f * SOUND_VOL)
} }
remaining <= FADE_MS -> { val outroElapsed = outro - remaining
// Финальное затухание шума. when {
val frac = remaining.toFloat() / FADE_MS outroElapsed < crossfade -> {
sleepSoundPlayer.setVolume((frac * frac) * SOUND_VOL) // Кроссфейд: радио вниз, шум вверх.
} val f = outroElapsed.toFloat() / crossfade
else -> { exoPlayer.volume = (1f - f).coerceIn(0f, 1f)
// Радио отыграло — на паузу, шум на комфортной громкости. sleepSoundPlayer.setVolume(f * SOUND_VOL)
if (exoPlayer.isPlaying) exoPlayer.pause() }
sleepSoundPlayer.setVolume(SOUND_VOL) remaining <= FADE_MS -> {
// Финальное затухание шума в тишину.
val frac = remaining.toFloat() / FADE_MS
sleepSoundPlayer.setVolume((frac * frac) * SOUND_VOL)
}
else -> {
// Радио отыграло — пауза, шум на комфортной громкости.
if (exoPlayer.isPlaying) exoPlayer.pause()
sleepSoundPlayer.setVolume(SOUND_VOL)
}
} }
delay(150)
} }
delay(150) sound == null && remaining <= FADE_MS -> {
} else { // Без звука: экспоненциальное затухание радио в конце.
if (remaining <= FADE_MS) {
val frac = remaining.toFloat() / FADE_MS val frac = remaining.toFloat() / FADE_MS
exoPlayer.volume = (frac * frac).coerceIn(0f, 1f) exoPlayer.volume = (frac * frac).coerceIn(0f, 1f)
delay(150) delay(150)
} else { }
else -> {
// Основная фаза: радио играет как обычно.
delay(1_000) delay(1_000)
} }
} }