feat(player): звуки для сна (белый/розовый/коричневый шум) + Smart Sleep Fade

SleepSoundPlayer — процедурная генерация цветного шума через AudioTrack (розовый —
фильтр Келлета, коричневый — random walk). В таймере сна выбор звука: радио плавно
перетекает в выбранный шум (кроссфейд ≤90с), шум играет, к концу затухает — как в
спеке («Smart Sleep Fade»). В шторке таймера — чипы выбора звука.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-06 15:08:32 +03:00
parent bda2c5b30f
commit 4411d53a6c
4 changed files with 207 additions and 16 deletions

View File

@@ -40,7 +40,8 @@ import javax.inject.Singleton
@UnstableApi
@Singleton
class PlayerController @Inject constructor(
@ApplicationContext context: Context
@ApplicationContext context: Context,
private val sleepSoundPlayer: SleepSoundPlayer
) {
// Анализатор спектра реального звука — для «живого» эквалайзера.
private val spectrumAnalyzer = AudioSpectrumAnalyzer()
@@ -84,6 +85,8 @@ class PlayerController @Inject constructor(
private companion object {
const val FADE_MS = 20_000L // длительность плавного затухания в конце таймера
const val CROSSFADE_MS = 90_000L // переход радио → звук для сна
const val SOUND_VOL = 0.6f // комфортная громкость шума
}
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
@@ -247,39 +250,71 @@ class PlayerController @Inject constructor(
}
/**
* Запустить таймер сна на [durationMs] мс. В последние FADE_MS звук плавно
* затухает (экспоненциально, чтобы спад воспринимался естественно), затем пауза.
* Запустить таймер сна на [durationMs] мс.
* Без [sound]: в последние FADE_MS радио экспоненциально затухает, затем пауза.
* Со [sound] («Smart Sleep Fade»): в начале радио кроссфейдится в звук для сна
* (радио ↓, шум ↑), затем шум играет, в конце затухает — как в спеке.
*/
fun startSleepTimer(durationMs: Long) {
fun startSleepTimer(durationMs: Long, sound: SleepSound? = null) {
sleepJob?.cancel()
exoPlayer.volume = 1f
sleepSoundPlayer.stop()
sleepJob = timerScope.launch {
val end = SystemClock.elapsedRealtime() + durationMs
val start = SystemClock.elapsedRealtime()
val end = start + durationMs
// Кроссфейд не длиннее трети таймера (для коротких интервалов).
val crossfade = if (sound != null) CROSSFADE_MS.coerceAtMost(durationMs / 3) else 0L
if (sound != null) sleepSoundPlayer.start(sound)
while (true) {
val remaining = end - SystemClock.elapsedRealtime()
val now = SystemClock.elapsedRealtime()
val remaining = end - now
if (remaining <= 0L) break
_sleepRemainingMs.value = remaining
if (remaining <= FADE_MS) {
// frac: 1 → 0; экспонента (frac^2) — громкость падает резче к концу.
val frac = remaining.toFloat() / FADE_MS
exoPlayer.volume = (frac * frac).coerceIn(0f, 1f)
if (sound != null) {
val elapsed = now - start
when {
elapsed < crossfade -> {
// Кроссфейд: радио вниз, шум вверх.
val f = elapsed.toFloat() / crossfade
exoPlayer.volume = (1f - f).coerceIn(0f, 1f)
sleepSoundPlayer.setVolume(f * 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)
} else {
delay(1_000)
if (remaining <= FADE_MS) {
val frac = remaining.toFloat() / FADE_MS
exoPlayer.volume = (frac * frac).coerceIn(0f, 1f)
delay(150)
} else {
delay(1_000)
}
}
}
exoPlayer.pause()
if (exoPlayer.isPlaying) exoPlayer.pause()
exoPlayer.volume = 1f
sleepSoundPlayer.stop()
_sleepRemainingMs.value = null
sleepJob = null
}
}
/** Отменить таймер сна и вернуть полную громкость. */
/** Отменить таймер сна, вернуть громкость и заглушить звук сна. */
fun cancelSleepTimer() {
sleepJob?.cancel()
sleepJob = null
exoPlayer.volume = 1f
sleepSoundPlayer.stop()
_sleepRemainingMs.value = null
}
@@ -299,6 +334,7 @@ class PlayerController @Inject constructor(
fun release() {
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
sleepSoundPlayer.stop()
exoPlayer.release()
}
}