Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c35256c
feat: 연습 녹음 기능 구현 및 홈 화면 연동
HamBeomJoon Apr 30, 2026
ce7bbbd
feat: 연습 녹음 분석 결과 및 상태(Loading/Error/Success) 화면 구현
HamBeomJoon Apr 30, 2026
0c901bb
feat: 연습 녹음 기능의 예외 처리 강화 및 상태 관리 리팩터링
HamBeomJoon May 1, 2026
eb8b122
refactor: 연습 녹음 기능 구조 개선 및 코드 리팩터링
HamBeomJoon May 1, 2026
24c2b47
refactor: 연습 녹음 분석 결과 화면 패키지 구조 개선 및 컴포넌트 정리
HamBeomJoon May 1, 2026
11f3854
feat: 연습 녹음 분석 기능 구현 및 관련 도메인/데이터 레이어 추가
HamBeomJoon May 1, 2026
9f90412
feat: 연습 녹음 화면 권한 처리 로직 개선 및 오류 처리 강화
HamBeomJoon May 2, 2026
cf07025
feat: 연습(Practice) 관련 UseCase KDoc 주석 추가
HamBeomJoon May 2, 2026
52331c6
refactor: 녹음 권한 요청 로직 간소화 및 오디오 컨트롤러 상태 초기화 로직 개선
HamBeomJoon May 2, 2026
97b3d74
feat: 공통 권한 요청 유틸리티 구현 및 오디오 녹음 로직 개선
HamBeomJoon May 3, 2026
3fc3663
feat: 연습 녹음 분석 기능 구현 및 오디오 로직 리팩터링
HamBeomJoon May 4, 2026
d8e4d9c
refactor: 오디오 녹음 로직 core 모듈 분리 및 권한 요청 로직 개선
HamBeomJoon May 4, 2026
aa8c634
feat: 음성 녹음 및 외부 오디오 파일 선택 기능 고도화
HamBeomJoon May 6, 2026
582aed0
Merge remote-tracking branch 'origin/develop' into feat/#101-practice…
HamBeomJoon May 8, 2026
7b06176
refactor: 홈 화면 코드 구조 개선 및 녹음 로직 간소화
HamBeomJoon May 8, 2026
ac2b6aa
refactor: 연습 녹음(Practice Recording) 상태 관리 및 결과 화면 로직 개선
HamBeomJoon May 8, 2026
92fc811
refactor: `RecordingAudioController` 내 `Immutable` 어노테이션 교체 및 의존성 정리
HamBeomJoon May 10, 2026
1ce3b6e
Merge remote-tracking branch 'origin/develop' into feat/#101-practice…
HamBeomJoon May 10, 2026
f2a0fa2
refactor: 오디오 관련 도메인 모델 및 상태 인터페이스 분리
HamBeomJoon May 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Prezel/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<application
android:name=".PrezelApplication"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import com.team.prezel.core.data.NetworkMonitor
import com.team.prezel.core.navigation.NavigationState
import com.team.prezel.core.navigation.rememberNavigationState
import com.team.prezel.feature.splash.api.SplashNavKey
import com.team.prezel.feature.home.api.HomeNavKey
import com.team.prezel.navigation.MAIN_NAV_KEYS
import com.team.prezel.navigation.TOP_LEVEL_KEYS
import kotlinx.coroutines.CoroutineScope
Expand All @@ -22,7 +22,7 @@ fun rememberPrezelAppState(
coroutineScope: CoroutineScope = rememberCoroutineScope(),
): PrezelAppState {
val navigationState = rememberNavigationState(
startKey = SplashNavKey,
startKey = HomeNavKey,
topLevelKeys = TOP_LEVEL_KEYS,
)

Expand Down
14 changes: 14 additions & 0 deletions Prezel/core/audio/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.prezel.android.library)
alias(libs.plugins.prezel.hilt)
}

android {
namespace = "com.team.prezel.core.audio"
}

dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.runtime)
Comment on lines +11 to +12
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: audio 모듈의 public API 클래스 사용 패턴 확인

# RecordingAudioController와 MediaRecordingAudioController가 `@Immutable을` 사용하는지 확인
echo "=== Checking `@Immutable` usage in core:audio ==="
fd -e kt . Prezel/core/audio/src/main/java -x rg -l "@Immutable"

# 다른 모듈에서 audio 모듈의 클래스를 import하는 위치 찾기
echo -e "\n=== Finding usages of audio module classes in other modules ==="
rg -n --type kotlin "import com\.team\.prezel\.core\.audio\." \
  -g '!Prezel/core/audio/**' \
  -C 2

Repository: Team-Prezel/Prezel-Android

Length of output: 6018


🏁 Script executed:

# Find all Kotlin files in audio module and search for `@Immutable`
fd -e kt . Prezel/core/audio/src -x cat -n {} | grep -i "@immutable" -B 2 -A 2

Repository: Team-Prezel/Prezel-Android

Length of output: 345


🏁 Script executed:

# Check if RecordingAudioController uses `@Immutable`
fd -e kt . Prezel/core/audio/src -x rg -l "class RecordingAudioController|class MediaRecordingAudioController"

Repository: Team-Prezel/Prezel-Android

Length of output: 162


🏁 Script executed:

# Look at current build.gradle.kts to see actual dependency declarations
cat Prezel/core/audio/build.gradle.kts

Repository: Team-Prezel/Prezel-Android

Length of output: 396


🏁 Script executed:

# Read RecordingAudioController to check for `@Immutable` annotation
cat -n Prezel/core/audio/src/main/java/com/team/prezel/core/audio/RecordingAudioController.kt | head -30

Repository: Team-Prezel/Prezel-Android

Length of output: 623


🏁 Script executed:

# Read MediaRecordingAudioController to check for `@Immutable` annotation
cat -n Prezel/core/audio/src/main/java/com/team/prezel/core/audio/MediaRecordingAudioController.kt | head -30

Repository: Team-Prezel/Prezel-Android

Length of output: 1354


🏁 Script executed:

# Check what files are in the audio module to see all public exports
fd -e kt . Prezel/core/audio/src/main/java -type f

Repository: Team-Prezel/Prezel-Android

Length of output: 241


Compose Runtime 의존성을 api로 노출하는 것을 권장합니다.

이 모듈의 public API 클래스인 AudioSessionStateAudioSource가 Compose의 @Immutable 어노테이션을 사용하므로, 소비 모듈(feature/home/impl의 Compose 화면들)에서 Compose stability inference가 올바르게 작동하려면 어노테이션이 노출되어야 합니다. 현재 implementation으로 선언되어 있으면 소비 모듈에서 어노테이션 메타데이터를 볼 수 없어 불필요한 recomposition이 발생할 수 있습니다.

✨ 권장 수정사항
 dependencies {
     implementation(platform(libs.androidx.compose.bom))
-    implementation(libs.androidx.compose.runtime)
+    api(libs.androidx.compose.runtime)
     implementation(libs.kotlinx.coroutines.core)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.runtime)
implementation(platform(libs.androidx.compose.bom))
api(libs.androidx.compose.runtime)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Prezel/core/audio/build.gradle.kts` around lines 11 - 12, The Compose runtime
dependency is declared as implementation so consumers cannot see Compose
annotations used by your public API (AudioSessionState, AudioSource); change the
dependency declaration for libs.androidx.compose.runtime from
implementation(...) to api(...) so the `@Immutable` annotation and stability
metadata are exposed to downstream modules and Compose stability inference works
correctly.

implementation(libs.kotlinx.coroutines.core)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.team.prezel.core.audio

sealed interface AudioSessionEffect {
data object RecordingStartFailed : AudioSessionEffect

data object RecordingStopFailed : AudioSessionEffect

data object PlaybackStartFailed : AudioSessionEffect
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.team.prezel.core.audio

import androidx.compose.runtime.Immutable

@Immutable
sealed interface AudioSessionState {
data object Idle : AudioSessionState

data class Recording(
val elapsedSeconds: Int,
) : AudioSessionState

data class ReadyToPlay(
val source: AudioSource,
val positionSeconds: Int = 0,
val durationSeconds: Int,
) : AudioSessionState

data class Playing(
val source: AudioSource,
val positionSeconds: Int,
val durationSeconds: Int,
) : AudioSessionState
}

@Immutable
sealed interface AudioSource {
val filePath: String

data class RecordedFile(
override val filePath: String,
) : AudioSource
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
package com.team.prezel.core.audio

import android.content.Context
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
import kotlin.math.max

internal class MediaRecordingAudioController @Inject constructor(
@param:ApplicationContext private val context: Context,
) : RecordingAudioController {
private val controllerScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

private val _audioSessionState = MutableStateFlow<AudioSessionState>(AudioSessionState.Idle)
override val audioSessionState: StateFlow<AudioSessionState> = _audioSessionState.asStateFlow()

private val _audioSessionEffect = Channel<AudioSessionEffect>(capacity = Channel.BUFFERED)
override val audioSessionEffect: Flow<AudioSessionEffect> = _audioSessionEffect.receiveAsFlow()

private var recorder: MediaRecorder? = null
private var player: MediaPlayer? = null
private var currentAudioFile: File? = null
private var recordingTimerJob: Job? = null
private var playbackTimerJob: Job? = null

override fun startRecording() {
runCatching {
stopPlayback()
releaseRecorder()
deleteCurrentAudioFile()

val file = File.createTempFile("recording_", ".m4a", context.cacheDir)
var pendingRecorder: MediaRecorder? = null
val newRecorder = runCatching {
val recorder = createMediaRecorder(context = context)
pendingRecorder = recorder
recorder.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setOutputFile(file.absolutePath)
prepare()
start()
}
}.getOrElse { throwable ->
pendingRecorder?.release()
file.delete()
throw throwable
}

recorder = newRecorder
currentAudioFile = file
_audioSessionState.value = AudioSessionState.Recording(elapsedSeconds = 0)
startRecordingTimer()
}.onFailure {
releaseRecorder()
deleteCurrentAudioFile()
_audioSessionState.value = AudioSessionState.Idle
emitEffect(AudioSessionEffect.RecordingStartFailed)
}
Comment thread
HamBeomJoon marked this conversation as resolved.
}

override fun stopRecording() {
val elapsedSeconds = when (val state = audioSessionState.value) {
is AudioSessionState.Recording -> state.elapsedSeconds
else -> return
}
val file = currentAudioFile ?: return emitEffect(AudioSessionEffect.RecordingStopFailed)

recordingTimerJob?.cancel()
runCatching {
recorder?.stop() ?: error("Recording is not active.")
max(elapsedSeconds, 0)
}.onSuccess { durationSeconds ->
releaseRecorder()
_audioSessionState.value = AudioSessionState.ReadyToPlay(
source = AudioSource.RecordedFile(filePath = file.absolutePath),
durationSeconds = durationSeconds,
)
}.onFailure {
releaseRecorder()
deleteCurrentAudioFile()
_audioSessionState.value = AudioSessionState.Idle
emitEffect(AudioSessionEffect.RecordingStopFailed)
}
}

override fun startPlayback() {
when (val state = audioSessionState.value) {
is AudioSessionState.ReadyToPlay -> startPlayback(
source = state.source,
durationSeconds = state.durationSeconds,
startPositionSeconds = state.positionSeconds.takeIf { it < state.durationSeconds } ?: 0,
)

else -> Unit
}
}

override fun stopPlayback() {
val readyState = when (val state = audioSessionState.value) {
is AudioSessionState.Playing -> AudioSessionState.ReadyToPlay(
source = state.source,
positionSeconds = playbackPositionSeconds().coerceAtLeast(state.positionSeconds),
durationSeconds = state.durationSeconds,
)

else -> null
}

releasePlayer()
if (readyState != null) {
_audioSessionState.value = readyState
}
}

override fun reset() {
recordingTimerJob?.cancel()
playbackTimerJob?.cancel()
releaseRecorder()
releasePlayer()
deleteCurrentAudioFile()
_audioSessionState.value = AudioSessionState.Idle
}

override fun release() {
reset()
controllerScope.cancel()
}

private fun startPlayback(
source: AudioSource,
durationSeconds: Int,
startPositionSeconds: Int,
) {
runCatching {
releasePlayer()

var pendingPlayer: MediaPlayer? = null
val newPlayer = runCatching {
val mediaPlayer = MediaPlayer()
pendingPlayer = mediaPlayer
mediaPlayer.apply {
setDataSource(source.filePath)
prepare()
if (startPositionSeconds > 0) {
seekTo(startPositionSeconds * MILLIS_PER_SECOND)
}
setOnCompletionListener {
releasePlayer()
_audioSessionState.value = AudioSessionState.ReadyToPlay(
source = source,
positionSeconds = durationSeconds,
durationSeconds = durationSeconds,
)
}
start()
}
}.getOrElse { throwable ->
pendingPlayer?.release()
throw throwable
}

player = newPlayer
_audioSessionState.value = AudioSessionState.Playing(
source = source,
positionSeconds = startPositionSeconds,
durationSeconds = durationSeconds.coerceAtLeast(newPlayer.duration.toSeconds()),
)
startPlaybackTimer()
}.onFailure {
releasePlayer()
_audioSessionState.value = AudioSessionState.ReadyToPlay(
source = source,
durationSeconds = durationSeconds,
)
emitEffect(AudioSessionEffect.PlaybackStartFailed)
}
}

private fun startRecordingTimer() {
recordingTimerJob?.cancel()
recordingTimerJob = controllerScope.launch {
while (true) {
delay(RECORDING_TIMER_DELAY_MILLIS)
_audioSessionState.update { state ->
if (state !is AudioSessionState.Recording) return@update state
AudioSessionState.Recording(elapsedSeconds = state.elapsedSeconds + 1)
}
}
}
}

private fun startPlaybackTimer() {
playbackTimerJob?.cancel()
playbackTimerJob = controllerScope.launch {
while (true) {
delay(PLAYBACK_TIMER_DELAY_MILLIS)
_audioSessionState.update { state ->
if (state !is AudioSessionState.Playing) return@update state
AudioSessionState.Playing(
source = state.source,
positionSeconds = playbackPositionSeconds(),
durationSeconds = state.durationSeconds,
)
}
}
}
}

private fun releaseRecorder() {
recorder?.release()
recorder = null
}

private fun releasePlayer() {
playbackTimerJob?.cancel()
player?.runCatching { stop() }
player?.release()
player = null
}

private fun playbackPositionSeconds(): Int = runCatching { player?.currentPosition?.toSeconds() }.getOrNull() ?: 0

private fun deleteCurrentAudioFile() {
currentAudioFile?.delete()
currentAudioFile = null
}

private fun emitEffect(effect: AudioSessionEffect) {
_audioSessionEffect.trySend(effect)
}

private companion object {
const val MILLIS_PER_SECOND = 1_000
const val RECORDING_TIMER_DELAY_MILLIS = 1_000L
const val PLAYBACK_TIMER_DELAY_MILLIS = 250L
}
}

@Suppress("DEPRECATION")
private fun createMediaRecorder(context: Context): MediaRecorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
MediaRecorder()
}

private fun Int.toSeconds(): Int = this / 1_000
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.team.prezel.core.audio

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow

interface RecordingAudioController {
val audioSessionState: StateFlow<AudioSessionState>

val audioSessionEffect: Flow<AudioSessionEffect>

fun startRecording()

fun stopRecording()

fun startPlayback()

fun stopPlayback()

fun reset()

fun release()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.team.prezel.core.audio

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.scopes.ViewModelScoped

@Module
@InstallIn(ViewModelComponent::class)
internal abstract class RecordingAudioModule {
@Binds
@ViewModelScoped
abstract fun bindRecordingAudioController(impl: MediaRecordingAudioController): RecordingAudioController
}
Loading
Loading