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 @UnstableApi
@Singleton @Singleton
class PlayerController @Inject constructor( class PlayerController @Inject constructor(
@ApplicationContext context: Context @ApplicationContext context: Context,
private val sleepSoundPlayer: SleepSoundPlayer
) { ) {
// Анализатор спектра реального звука — для «живого» эквалайзера. // Анализатор спектра реального звука — для «живого» эквалайзера.
private val spectrumAnalyzer = AudioSpectrumAnalyzer() private val spectrumAnalyzer = AudioSpectrumAnalyzer()
@@ -84,6 +85,8 @@ 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 SOUND_VOL = 0.6f // комфортная громкость шума
} }
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager 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() sleepJob?.cancel()
exoPlayer.volume = 1f exoPlayer.volume = 1f
sleepSoundPlayer.stop()
sleepJob = timerScope.launch { 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) { while (true) {
val remaining = end - SystemClock.elapsedRealtime() val now = SystemClock.elapsedRealtime()
val remaining = end - now
if (remaining <= 0L) break if (remaining <= 0L) break
_sleepRemainingMs.value = remaining _sleepRemainingMs.value = remaining
if (remaining <= FADE_MS) { if (sound != null) {
// frac: 1 → 0; экспонента (frac^2) — громкость падает резче к концу. val elapsed = now - start
val frac = remaining.toFloat() / FADE_MS when {
exoPlayer.volume = (frac * frac).coerceIn(0f, 1f) 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) delay(150)
} else { } 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 exoPlayer.volume = 1f
sleepSoundPlayer.stop()
_sleepRemainingMs.value = null _sleepRemainingMs.value = null
sleepJob = null sleepJob = null
} }
} }
/** Отменить таймер сна и вернуть полную громкость. */ /** Отменить таймер сна, вернуть громкость и заглушить звук сна. */
fun cancelSleepTimer() { fun cancelSleepTimer() {
sleepJob?.cancel() sleepJob?.cancel()
sleepJob = null sleepJob = null
exoPlayer.volume = 1f exoPlayer.volume = 1f
sleepSoundPlayer.stop()
_sleepRemainingMs.value = null _sleepRemainingMs.value = null
} }
@@ -299,6 +334,7 @@ class PlayerController @Inject constructor(
fun release() { fun release() {
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback) audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
sleepSoundPlayer.stop()
exoPlayer.release() exoPlayer.release()
} }
} }

View File

@@ -0,0 +1,120 @@
package com.radiola.service
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioTrack
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.random.Random
/** Тип звука для засыпания (процедурная генерация цветного шума). */
enum class SleepSound(val key: String, val title: String) {
WHITE("white", "Белый шум"),
PINK("pink", "Розовый шум"),
BROWN("brown", "Коричневый шум");
companion object {
fun fromKey(key: String?): SleepSound? = entries.firstOrNull { it.key == key }
}
}
/**
* Проигрыватель цветного шума для засыпания. Генерирует PCM на отдельном потоке и
* пишет в [AudioTrack] (streaming). Громкость регулируется на лету (для fade/кроссфейда).
* Розовый — фильтр Пола Келлета, коричневый — интегрированный белый (random walk).
*/
@Singleton
class SleepSoundPlayer @Inject constructor() {
private val sampleRate = 44100
private val bufSize = AudioTrack.getMinBufferSize(
sampleRate,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
).coerceAtLeast(4096)
@Volatile private var track: AudioTrack? = null
@Volatile private var thread: Thread? = null
@Volatile private var running = false
/** Запустить генерацию шума [sound]. Стартовая громкость 0 — нарастает кроссфейдом. */
@Synchronized
fun start(sound: SleepSound) {
stop()
val at = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build(),
)
.setAudioFormat(
AudioFormat.Builder()
.setSampleRate(sampleRate)
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build(),
)
.setBufferSizeInBytes(bufSize * 2)
.setTransferMode(AudioTrack.MODE_STREAM)
.build()
at.setVolume(0f)
at.play()
track = at
running = true
thread = Thread { generate(at, sound) }.apply { priority = Thread.MIN_PRIORITY; start() }
}
/** Громкость 0..1 (для плавного появления/затухания). */
fun setVolume(v: Float) {
track?.setVolume(v.coerceIn(0f, 1f))
}
@Synchronized
fun stop() {
running = false
thread?.let { runCatching { it.join(300) } }
thread = null
track?.let { t ->
runCatching { t.stop() }
runCatching { t.release() }
}
track = null
}
private fun generate(at: AudioTrack, sound: SleepSound) {
val n = 2048
val buf = ShortArray(n)
// Состояние фильтров розового шума (Пол Келлет)
var b0 = 0f; var b1 = 0f; var b2 = 0f; var b3 = 0f; var b4 = 0f; var b5 = 0f; var b6 = 0f
var lastBrown = 0f
while (running) {
for (i in 0 until n) {
val white = Random.nextFloat() * 2f - 1f
val sample = when (sound) {
SleepSound.WHITE -> white * 0.35f
SleepSound.PINK -> {
b0 = 0.99886f * b0 + white * 0.0555179f
b1 = 0.99332f * b1 + white * 0.0750759f
b2 = 0.96900f * b2 + white * 0.1538520f
b3 = 0.86650f * b3 + white * 0.3104856f
b4 = 0.55000f * b4 + white * 0.5329522f
b5 = -0.7616f * b5 - white * 0.0168980f
val pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362f
b6 = white * 0.115926f
pink * 0.11f
}
SleepSound.BROWN -> {
val brown = (lastBrown + 0.02f * white) / 1.02f
lastBrown = brown
brown * 3.5f
}
}
buf[i] = (sample.coerceIn(-1f, 1f) * Short.MAX_VALUE).toInt().toShort()
}
val written = at.write(buf, 0, n)
if (written < 0) break
}
}
}

View File

@@ -84,6 +84,7 @@ fun PlayerBottomSheet(
var showLyrics by remember { mutableStateOf(false) } var showLyrics by remember { mutableStateOf(false) }
var showQuality by remember { mutableStateOf(false) } var showQuality by remember { mutableStateOf(false) }
var showSleep by remember { mutableStateOf(false) } var showSleep by remember { mutableStateOf(false) }
var selectedSound by remember { mutableStateOf<com.radiola.service.SleepSound?>(null) }
val currentQuality by viewModel.currentQuality.collectAsState() val currentQuality by viewModel.currentQuality.collectAsState()
val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState() val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
val spectrum by viewModel.spectrum.collectAsState() val spectrum by viewModel.spectrum.collectAsState()
@@ -526,11 +527,23 @@ fun PlayerBottomSheet(
modifier = Modifier.padding(vertical = 12.dp) modifier = Modifier.padding(vertical = 12.dp)
) )
Text( Text(
text = "Музыка плавно затихнет к концу и поставится на паузу.", text = "Музыка плавно затихнет к концу. Можно мягко перейти на звук для сна.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = colors.textSecondary, color = colors.textSecondary,
modifier = Modifier.padding(bottom = 12.dp) modifier = Modifier.padding(bottom = 12.dp)
) )
// Выбор звука для сна: радио плавно перетечёт в выбранный шум.
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(bottom = 12.dp)
) {
item {
SoundChip("Без звука", selectedSound == null) { selectedSound = null }
}
items(com.radiola.service.SleepSound.entries) { snd ->
SoundChip(snd.title, selectedSound == snd) { selectedSound = snd }
}
}
// Если активен — показываем остаток и кнопку отмены // Если активен — показываем остаток и кнопку отмены
if (sleepRemainingMs != null) { if (sleepRemainingMs != null) {
SleepRow( SleepRow(
@@ -550,7 +563,7 @@ fun PlayerBottomSheet(
selected = false, selected = false,
onClick = { onClick = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
viewModel.startSleepTimer(min) viewModel.startSleepTimer(min, selectedSound)
showSleep = false showSleep = false
} }
) )
@@ -601,6 +614,27 @@ private fun SleepRow(
} }
} }
/** Чип выбора звука для сна. */
@Composable
private fun SoundChip(label: String, selected: Boolean, onClick: () -> Unit) {
val colors = RadiolaTheme.colors
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
color = if (selected) colors.bgBase else colors.textSecondary,
fontWeight = FontWeight.Medium,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(if (selected) colors.accent else colors.surface2)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
)
.padding(horizontal = 14.dp, vertical = 9.dp)
)
}
/** Форматирует оставшееся время таймера сна в M:SS / MM:SS. */ /** Форматирует оставшееся время таймера сна в M:SS / MM:SS. */
private fun formatSleep(ms: Long): String { private fun formatSleep(ms: Long): String {
val total = (ms / 1000).coerceAtLeast(0) val total = (ms / 1000).coerceAtLeast(0)

View File

@@ -75,7 +75,8 @@ class PlayerViewModel @Inject constructor(
// Таймер сна: оставшееся время в мс (null = выключен). // Таймер сна: оставшееся время в мс (null = выключен).
val sleepRemainingMs: StateFlow<Long?> = playerController.sleepRemainingMs val sleepRemainingMs: StateFlow<Long?> = playerController.sleepRemainingMs
fun startSleepTimer(minutes: Int) = playerController.startSleepTimer(minutes * 60_000L) fun startSleepTimer(minutes: Int, sound: com.radiola.service.SleepSound? = null) =
playerController.startSleepTimer(minutes * 60_000L, sound)
fun cancelSleepTimer() = playerController.cancelSleepTimer() fun cancelSleepTimer() = playerController.cancelSleepTimer()
private var nowPlayingJob: Job? = null private var nowPlayingJob: Job? = null