diff --git a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt index a78224ea..8192be15 100644 --- a/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt +++ b/Prezel/core/designsystem/src/main/java/com/team/prezel/core/designsystem/component/voice/PrezelVoiceChromeWave.kt @@ -1,6 +1,9 @@ package com.team.prezel.core.designsystem.component.voice import androidx.annotation.FloatRange +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,6 +13,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -31,35 +35,64 @@ import com.team.prezel.core.designsystem.theme.PrezelTheme import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -import kotlin.math.roundToInt +import kotlin.math.ceil private const val MIN_REACTIVE_VOLUME = 0.12f private const val MIN_WAVE_VOLUME = 0.1f - +private const val RAW_SAMPLES_PER_WAVE_BAR = 2 +private const val WAVE_SCROLL_DURATION_MILLIS = 100 + +/** + * 새 음량 샘플이 오른쪽에서 들어오고 기존 기록이 왼쪽으로 흐르는 Voice 파형이다. + * + * [volumes]는 무음 구간을 포함해 50ms 주기로 쌓인 값이어야 한다. 녹음과 재생의 streaming 파형은 + * 원본 샘플 두 개의 최고값을 100ms짜리 막대 하나로 합쳐 떨림을 줄이고, 무음도 최소 높이로 이동시킨다. + * [usesStreamingLayout]은 녹음·녹음 일시정지·재생 상태에서 파형 기록을 오른쪽 기준으로 배치할 때 사용한다. + * 실제 이동은 [status]가 [VoiceChromeStatus.LISTENING]일 때만 진행해 일시정지 중에는 현재 파형을 유지한다. + * 수평 위치는 현재 막대 개수를 목표로 하는 절대 scroll position으로 계산한다. 새 막대가 추가된 + * 프레임에서도 기존 막대의 좌표가 유지되므로, list 갱신과 이후 offset 보정이 엇갈려 발생하던 좌우 떨림을 방지한다. + * 각 갱신은 남은 거리와 무관하게 100ms 안에 최신 목표에 도달해, 샘플 간격의 미세한 오차가 누적되어 파형이 화면 밖으로 밀리지 않게 한다. + */ @Composable fun PrezelVoiceChromeWave( modifier: Modifier = Modifier, status: VoiceChromeStatus = VoiceChromeStatus.IDLE, volumes: ImmutableList = persistentListOf(), showBaseline: Boolean = true, + usesStreamingLayout: Boolean = false, ) { - val adjustedVolumes = when (status) { - VoiceChromeStatus.IDLE -> persistentListOf() + val adjustedVolumes = remember(status, volumes, usesStreamingLayout) { + volumes.adjustForVoiceChrome( + status = status, + usesStreamingLayout = usesStreamingLayout, + ) + } + val scrollPosition = remember(usesStreamingLayout) { + Animatable(adjustedVolumes.size.toFloat()) + } - VoiceChromeStatus.LISTENING, - VoiceChromeStatus.WAITING, - -> { - val clippedVolumes = volumes - .filter { volume -> volume > MIN_REACTIVE_VOLUME } - .map { volume -> - volume.coerceIn( - minimumValue = MIN_WAVE_VOLUME, - maximumValue = 1f, - ) - } + LaunchedEffect(status, adjustedVolumes.size, usesStreamingLayout) { + val targetPosition = adjustedVolumes.size.toFloat() - clippedVolumes.toImmutableList() + if (!usesStreamingLayout || adjustedVolumes.isEmpty()) { + scrollPosition.snapTo(targetPosition) + return@LaunchedEffect + } + if (targetPosition < scrollPosition.value) { + scrollPosition.snapTo(targetPosition) + return@LaunchedEffect + } + if (status != VoiceChromeStatus.LISTENING || targetPosition == scrollPosition.value) { + return@LaunchedEffect } + + scrollPosition.animateTo( + targetValue = targetPosition, + animationSpec = tween( + durationMillis = WAVE_SCROLL_DURATION_MILLIS, + easing = LinearEasing, + ), + ) } Spacer( @@ -67,55 +100,103 @@ fun PrezelVoiceChromeWave( status = status, volumes = adjustedVolumes, showBaseline = showBaseline, + usesStreamingLayout = usesStreamingLayout, + scrollPositionProvider = { scrollPosition.value }, ), ) } +/** + * 무음은 최소 높이로 변환하고 음성 입력값은 막대 높이 계산 범위 안으로 보정한다. + * 라이브 녹음에서는 50ms 샘플 두 개의 최고값을 사용해 응답성은 유지하면서 갱신 빈도를 낮춘다. + */ +private fun ImmutableList.adjustForVoiceChrome( + status: VoiceChromeStatus, + usesStreamingLayout: Boolean, +): ImmutableList = + when (status) { + VoiceChromeStatus.IDLE -> persistentListOf() + + VoiceChromeStatus.LISTENING, + VoiceChromeStatus.WAITING, + -> { + val clippedVolumes = this + .map { volume -> + if (volume <= MIN_REACTIVE_VOLUME) { + MIN_WAVE_VOLUME + } else { + volume.coerceIn( + minimumValue = MIN_WAVE_VOLUME, + maximumValue = 1f, + ) + } + } + + if (!usesStreamingLayout) { + clippedVolumes.toImmutableList() + } else { + List(clippedVolumes.size / RAW_SAMPLES_PER_WAVE_BAR) { barIndex -> + val firstSampleIndex = barIndex * RAW_SAMPLES_PER_WAVE_BAR + maxOf( + clippedVolumes[firstSampleIndex], + clippedVolumes[firstSampleIndex + 1], + ) + }.toImmutableList() + } + } + } + @Composable private fun Modifier.drawVoiceChromeWave( status: VoiceChromeStatus, volumes: ImmutableList, showBaseline: Boolean, + usesStreamingLayout: Boolean, + scrollPositionProvider: () -> Float, ): Modifier { val colors = PrezelTheme.colors - return fillMaxWidth().height(60.dp).drawWithCache { - val barWidth = 2.dp.toPx() - val barSpacing = 6.dp.toPx() - val minBarHeight = 4.dp.toPx() - val maxBarHeight = 40.dp.toPx() - val barRadius = CornerRadius(barWidth / 2f, barWidth / 2f) - val activeBrush = Brush.horizontalGradient( - colorStops = arrayOf( - 0f to colors.interactiveSmall, - 0.5f to colors.interactiveRegular, - 1f to colors.interactiveSmall, - ), - startX = 0f, - endX = size.width, - ) - val drawConfig = VoiceChromeWaveDrawConfig( - barWidth = barWidth, - barSpacing = barSpacing, - minBarHeight = minBarHeight, - maxBarHeight = maxBarHeight, - barRadius = barRadius, - activeBrush = activeBrush, - waitingColor = colors.interactiveXSmall, - idleColor = colors.bgDisabled, - baselineStrokeWidth = 1.dp.toPx(), - ) - - onDrawBehind { - drawVoiceChromeWaveContent( - status = status, - volumes = volumes, - config = drawConfig, - showBaseline = showBaseline, - baselineColor = colors.borderRegular, + return fillMaxWidth() + .height(60.dp) + .drawWithCache { + val barWidth = 2.dp.toPx() + val barSpacing = 6.dp.toPx() + val minBarHeight = 4.dp.toPx() + val maxBarHeight = 40.dp.toPx() + val barRadius = CornerRadius(barWidth / 2f, barWidth / 2f) + val activeBrush = Brush.horizontalGradient( + colorStops = arrayOf( + 0f to colors.interactiveSmall, + 0.5f to colors.interactiveRegular, + 1f to colors.interactiveSmall, + ), + startX = 0f, + endX = size.width, + ) + val drawConfig = VoiceChromeWaveDrawConfig( + barWidth = barWidth, + barSpacing = barSpacing, + minBarHeight = minBarHeight, + maxBarHeight = maxBarHeight, + barRadius = barRadius, + activeBrush = activeBrush, + waitingColor = colors.interactiveXSmall, + idleColor = colors.bgDisabled, + baselineStrokeWidth = 1.dp.toPx(), ) + + onDrawBehind { + drawVoiceChromeWaveContent( + status = status, + volumes = volumes, + config = drawConfig, + showBaseline = showBaseline, + baselineColor = colors.borderRegular, + usesStreamingLayout = usesStreamingLayout, + scrollPosition = scrollPositionProvider(), + ) + } } - } } private data class VoiceChromeWaveDrawConfig( @@ -136,11 +217,15 @@ private fun DrawScope.drawVoiceChromeWaveContent( config: VoiceChromeWaveDrawConfig, showBaseline: Boolean, baselineColor: Color, + usesStreamingLayout: Boolean, + scrollPosition: Float, ) { drawVoiceChromeWaveBars( status = status, volumes = volumes, config = config, + usesStreamingLayout = usesStreamingLayout, + scrollPosition = scrollPosition, ) drawVoiceChromeWaveBaseline( @@ -150,50 +235,141 @@ private fun DrawScope.drawVoiceChromeWaveContent( ) } +/** + * 아직 샘플이 채워지지 않은 영역에만 고정 기준선을 그리고 실제 샘플은 별도로 이동시킨다. + * 기준선을 전체 폭에 그리면 이동 중인 파란 막대 사이로 회색 막대가 노출되어 서로 미는 것처럼 보인다. + * 최신 샘플을 한 칸 오른쪽 바깥에서 시작하면 기존 파형의 위치가 끊기지 않고 이어진다. + */ private fun DrawScope.drawVoiceChromeWaveBars( status: VoiceChromeStatus, volumes: ImmutableList, config: VoiceChromeWaveDrawConfig, + usesStreamingLayout: Boolean, + scrollPosition: Float, ) { - var barX = -config.barWidth - var barIndex = 0 - val barCount = (size.width / config.barSpacing).roundToInt() + 1 - - while (barX < size.width + config.barSpacing) { - val volume = volumes.sampleVolume( - index = barIndex, - sampleCount = barCount, + val barCount = ceil(size.width / config.barSpacing).toInt() + val visibleSampleCount = volumes.size.coerceAtMost( + if (usesStreamingLayout) barCount + 1 else barCount, + ) + val filledBarCount = if (status == VoiceChromeStatus.IDLE) { + 0 + } else { + visibleSampleCount.coerceAtMost(barCount) + } + + repeat(barCount - filledBarCount) { emptyIndex -> + val emptyBarIndex = filledBarCount + emptyIndex + val barX = if (usesStreamingLayout) { + size.width - config.barWidth - (emptyBarIndex * config.barSpacing) + } else { + emptyBarIndex * config.barSpacing + } + drawVoiceChromeWaveBar( + status = VoiceChromeStatus.IDLE, + volume = MIN_WAVE_VOLUME, + barX = barX, + config = config, ) - val barHeight = config.volumeToBarHeight(volume) - val barTop = (size.height - barHeight) / 2f - val topLeft = Offset(x = barX, y = barTop) - val barSize = Size(width = config.barWidth, height = barHeight) - - when (status) { - VoiceChromeStatus.IDLE -> drawRoundRect( - color = config.idleColor, - topLeft = topLeft, - size = barSize, - cornerRadius = config.barRadius, - ) + } - VoiceChromeStatus.LISTENING -> drawRoundRect( - brush = config.activeBrush, - topLeft = topLeft, - size = barSize, - cornerRadius = config.barRadius, - ) + if ( + usesStreamingLayout && + status != VoiceChromeStatus.IDLE && + scrollPosition < volumes.size.toFloat() + ) { + drawVoiceChromeWaveBar( + status = VoiceChromeStatus.IDLE, + volume = MIN_WAVE_VOLUME, + barX = size.width - config.barWidth, + config = config, + ) + } + + if (status == VoiceChromeStatus.IDLE) return - VoiceChromeStatus.WAITING -> drawRoundRect( - color = config.waitingColor, - topLeft = topLeft, - size = barSize, - cornerRadius = config.barRadius, + val firstVisibleIndex = volumes.size - visibleSampleCount + + repeat(visibleSampleCount) { visibleIndex -> + val volume = if (!usesStreamingLayout && volumes.size > barCount) { + volumes.maxVolumeInBucket( + bucketIndex = visibleIndex, + bucketCount = barCount, ) + } else { + volumes[firstVisibleIndex + visibleIndex] + } + val sampleIndex = firstVisibleIndex + visibleIndex + val barX = if (usesStreamingLayout) { + val distanceFromRight = scrollPosition - 1f - sampleIndex + size.width - config.barWidth - + (distanceFromRight * config.barSpacing) + } else { + visibleIndex * config.barSpacing } - barX += config.barSpacing - barIndex += 1 + if (barX + config.barWidth <= 0f || barX >= size.width) return@repeat + + drawVoiceChromeWaveBar( + status = status, + volume = volume, + barX = barX, + config = config, + ) + } +} + +/** + * 완성된 녹음의 전체 시간축을 [bucketCount]개의 막대로 축약한다. + * 각 구간의 최고값을 사용해 짧은 음성 peak가 overview에서 사라지지 않게 한다. + */ +private fun ImmutableList.maxVolumeInBucket( + bucketIndex: Int, + bucketCount: Int, +): Float { + val startIndex = bucketIndex * size / bucketCount + val endIndex = ((bucketIndex + 1) * size / bucketCount) + .coerceAtLeast(startIndex + 1) + + var maxVolume = MIN_WAVE_VOLUME + for (sampleIndex in startIndex until endIndex) { + maxVolume = maxOf(maxVolume, this[sampleIndex]) + } + + return maxVolume +} + +private fun DrawScope.drawVoiceChromeWaveBar( + status: VoiceChromeStatus, + volume: Float, + barX: Float, + config: VoiceChromeWaveDrawConfig, +) { + val barHeight = config.volumeToBarHeight(volume) + val barTop = (size.height - barHeight) / 2f + val topLeft = Offset(x = barX, y = barTop) + val barSize = Size(width = config.barWidth, height = barHeight) + + when (status) { + VoiceChromeStatus.IDLE -> drawRoundRect( + color = config.idleColor, + topLeft = topLeft, + size = barSize, + cornerRadius = config.barRadius, + ) + + VoiceChromeStatus.LISTENING -> drawRoundRect( + brush = config.activeBrush, + topLeft = topLeft, + size = barSize, + cornerRadius = config.barRadius, + ) + + VoiceChromeStatus.WAITING -> drawRoundRect( + color = config.waitingColor, + topLeft = topLeft, + size = barSize, + cornerRadius = config.barRadius, + ) } } @@ -218,18 +394,6 @@ private fun VoiceChromeWaveDrawConfig.volumeToBarHeight(volume: Float): Float { return minBarHeight + volumeProgress * (maxBarHeight - minBarHeight) } -private fun ImmutableList.sampleVolume( - index: Int, - sampleCount: Int, -): Float { - if (isEmpty() || sampleCount <= 0) return MIN_WAVE_VOLUME - - val firstVisibleIndex = (size - sampleCount).coerceAtLeast(0) - val sampleIndex = firstVisibleIndex + index - - return getOrElse(sampleIndex) { MIN_WAVE_VOLUME } -} - @LargeDevicePreview @Composable private fun PrezelVoiceChromeWaveComponentPreview() { diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt index 7c4cb2bf..bab56899 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/AnalysisScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -43,6 +44,9 @@ private const val RECORDING_VOLUME_SAMPLE_INTERVAL_MILLIS = 50 private const val VOICE_RECORDING_FEEDBACK_DURATION_MILLIS = 3_000 private const val VOICE_RECORDING_FEEDBACK_SAMPLE_COUNT = VOICE_RECORDING_FEEDBACK_DURATION_MILLIS / RECORDING_VOLUME_SAMPLE_INTERVAL_MILLIS +private const val VOICE_RECORDING_FEEDBACK_UPDATE_INTERVAL_MILLIS = 1_000 +private const val VOICE_RECORDING_FEEDBACK_UPDATE_SAMPLE_COUNT = + VOICE_RECORDING_FEEDBACK_UPDATE_INTERVAL_MILLIS / RECORDING_VOLUME_SAMPLE_INTERVAL_MILLIS private const val SILENCE_VOLUME_THRESHOLD = 0.12f private const val LOW_AVERAGE_VOLUME_THRESHOLD = 0.25f @@ -52,6 +56,7 @@ internal fun AnalysisScreen( navigateToHome: () -> Unit, navigateToStep: (step: AnalysisFlowStep, clearStack: Boolean) -> Unit, navigateToReport: (presentationId: Long) -> Unit, + shouldShowVoiceRecordingGuide: Boolean, viewModel: AnalysisFlowViewModel, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value @@ -89,6 +94,7 @@ internal fun AnalysisScreen( AnalysisScreen( uiState = uiState, isScriptExpanded = isScriptExpanded, + shouldShowVoiceRecordingGuide = shouldShowVoiceRecordingGuide, onIntent = viewModel::onIntent, onScriptExpandedChange = { isScriptExpanded = it }, ) @@ -124,16 +130,23 @@ private fun AnalysisFlowUiState.statusBarStyle(isScriptExpanded: Boolean): EdgeT private fun AnalysisScreen( uiState: AnalysisFlowUiState, isScriptExpanded: Boolean, + shouldShowVoiceRecordingGuide: Boolean, onIntent: (AnalysisFlowUiIntent) -> Unit, onScriptExpandedChange: (Boolean) -> Unit, ) { val resources = LocalResources.current val snackbarHostState = LocalSnackbarHostState.current - val voiceRecordingFeedback = uiState.voiceRecordingFeedback + val voiceRecordingFeedback = uiState.rememberVoiceRecordingFeedback() val voiceRecordingChromeUi = voiceRecordingFeedback?.toChromeUi() - - LaunchedEffect(uiState.step) { - if (uiState.step == AnalysisFlowStep.VOICE_RECORDING) { + var hasShownVoiceRecordingGuide by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(shouldShowVoiceRecordingGuide, uiState.step) { + if ( + shouldShowVoiceRecordingGuide && + !hasShownVoiceRecordingGuide && + uiState.step == AnalysisFlowStep.VOICE_RECORDING + ) { + hasShownVoiceRecordingGuide = true snackbarHostState.showPrezelSnackbar( message = resources.getString(R.string.feature_analysis_impl_voice_recording_guide), ) @@ -179,6 +192,20 @@ private fun VoiceRecordingFeedback.toChromeUi(): VoiceRecordingChromeUi = ) } +/** + * 상단 녹음 안내가 너무 빨리 바뀌지 않도록 최근 3초 음량을 1초마다 확인한다. + */ +@Composable +private fun AnalysisFlowUiState.rememberVoiceRecordingFeedback(): VoiceRecordingFeedback? { + val isRecording = step == AnalysisFlowStep.VOICE_RECORDING && + recordingState is AudioSessionState.Recording + val feedbackUpdateIndex = recordingVolumes.size / VOICE_RECORDING_FEEDBACK_UPDATE_SAMPLE_COUNT + + return remember(isRecording, feedbackUpdateIndex) { + if (isRecording) voiceRecordingFeedback else null + } +} + private val AnalysisFlowUiState.voiceRecordingFeedback: VoiceRecordingFeedback? get() { if (step != AnalysisFlowStep.VOICE_RECORDING || recordingState !is AudioSessionState.Recording) return null diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt index 933a6b02..45f9594b 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/navigation/AnalysisEntryBuilder.kt @@ -81,6 +81,7 @@ private fun AnalysisRoute( AnalysisScreen( onBack = { navigator.goBack() }, + shouldShowVoiceRecordingGuide = enterIntent.shouldShowVoiceRecordingGuide, navigateToStep = { step, clearStack -> navigator.navigate( key = stepToNavKey(step), @@ -103,6 +104,17 @@ private fun AnalysisRoute( ) } +/** + * 화면 전환 중 이전 화면에서 녹음 안내가 다시 뜨지 않도록, + * 실제 녹음 화면으로 들어가는 경우에만 true를 반환한다. + */ +private val AnalysisFlowUiIntent.shouldShowVoiceRecordingGuide: Boolean + get() = when (this) { + is AnalysisFlowUiIntent.EnterStep -> step == AnalysisFlowStep.VOICE_RECORDING + is AnalysisFlowUiIntent.StartReRecording -> true + else -> false + } + private fun AnalysisNavKey.toNavKey(step: AnalysisFlowStep): AnalysisNavKey = when (step) { AnalysisFlowStep.PRESENTATION_SCHEDULE -> AnalysisNavKey.Schedule(flowId = flowId, startType = startType) diff --git a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt index c2567950..415c1d8f 100644 --- a/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt +++ b/Prezel/feature/analysis/impl/src/main/java/com/team/prezel/feature/analysis/impl/recording/VoiceRecordingContent.kt @@ -1,7 +1,7 @@ package com.team.prezel.feature.analysis.impl.recording +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -20,9 +20,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -288,26 +288,43 @@ private fun RecordingWaveform( status = voiceChromeUi?.status ?: recordingState.toVoiceChromeStatus(), volumes = visibleVolumes, showBaseline = false, + usesStreamingLayout = recordingState is AudioSessionState.Recording || + recordingState is AudioSessionState.PausedRecording || + recordingState is AudioSessionState.Playing, modifier = modifier, ) } +/** + * 1초 단위로 수신하는 재생 위치를 선형 보간해 50ms 원본 샘플을 시간순으로 공급한다. + * VoiceChrome은 원본 샘플 두 개를 100ms 막대로 합치므로 녹음과 재생이 같은 속도로 이동한다. + */ @Composable private fun AudioSessionState.playbackProgress(): Float { if (this !is AudioSessionState.Playing) return playbackProgress - return key(source, durationSeconds) { - val animatedPlaybackProgress by animateFloatAsState( - targetValue = playbackProgress, + val animatedPlaybackProgress = remember(source, durationSeconds) { + Animatable(initialValue = playbackProgress) + } + + LaunchedEffect(positionSeconds, durationSeconds) { + val nextPositionSeconds = (positionSeconds + 1).coerceAtMost(durationSeconds) + val nextProgress = if (durationSeconds <= 0) { + 1f + } else { + nextPositionSeconds.toFloat() / durationSeconds + } + + animatedPlaybackProgress.animateTo( + targetValue = nextProgress, animationSpec = tween( - durationMillis = 1000, + durationMillis = 1_000, easing = LinearEasing, ), - label = "RecordingWaveformPlaybackProgress", ) - - animatedPlaybackProgress } + + return animatedPlaybackProgress.value } private fun AudioSessionState.visibleRecordingVolumes(