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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
120
app/src/main/java/com/radiola/service/SleepSoundPlayer.kt
Normal file
120
app/src/main/java/com/radiola/service/SleepSoundPlayer.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ fun PlayerBottomSheet(
|
||||
var showLyrics by remember { mutableStateOf(false) }
|
||||
var showQuality 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 sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState()
|
||||
val spectrum by viewModel.spectrum.collectAsState()
|
||||
@@ -526,11 +527,23 @@ fun PlayerBottomSheet(
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Музыка плавно затихнет к концу и поставится на паузу.",
|
||||
text = "Музыка плавно затихнет к концу. Можно мягко перейти на звук для сна.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = colors.textSecondary,
|
||||
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) {
|
||||
SleepRow(
|
||||
@@ -550,7 +563,7 @@ fun PlayerBottomSheet(
|
||||
selected = false,
|
||||
onClick = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
viewModel.startSleepTimer(min)
|
||||
viewModel.startSleepTimer(min, selectedSound)
|
||||
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. */
|
||||
private fun formatSleep(ms: Long): String {
|
||||
val total = (ms / 1000).coerceAtLeast(0)
|
||||
|
||||
@@ -75,7 +75,8 @@ class PlayerViewModel @Inject constructor(
|
||||
// Таймер сна: оставшееся время в мс (null = выключен).
|
||||
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()
|
||||
|
||||
private var nowPlayingJob: Job? = null
|
||||
|
||||
Reference in New Issue
Block a user