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
|
@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,20 +250,49 @@ 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 (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 {
|
||||||
if (remaining <= FADE_MS) {
|
if (remaining <= FADE_MS) {
|
||||||
// frac: 1 → 0; экспонента (frac^2) — громкость падает резче к концу.
|
|
||||||
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)
|
||||||
@@ -268,18 +300,21 @@ class PlayerController @Inject constructor(
|
|||||||
delay(1_000)
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user