diff --git a/.gitignore b/.gitignore index ce7e3be..9438ff4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ local.properties .kotlin .idea -examplekey.jks +examplekey.jks \ No newline at end of file diff --git a/app/release/app-release.aab b/app/release/app-release.aab new file mode 100644 index 0000000..7ef6524 Binary files /dev/null and b/app/release/app-release.aab differ diff --git a/data/src/main/java/com/saegil/data/di/DataModule.kt b/data/src/main/java/com/saegil/data/di/DataModule.kt index fc62ad3..9e267d8 100644 --- a/data/src/main/java/com/saegil/data/di/DataModule.kt +++ b/data/src/main/java/com/saegil/data/di/DataModule.kt @@ -3,17 +3,21 @@ package com.saegil.data.di import android.content.Context import com.saegil.data.local.TokenDataSource import com.saegil.data.local.TokenDataSourceImpl +import com.saegil.data.remote.AssistantService import com.saegil.data.remote.FeedService import com.saegil.data.remote.MapService import com.saegil.data.remote.OAuthService import com.saegil.data.remote.ScenarioService import com.saegil.data.remote.SimulationLogService +import com.saegil.data.remote.TextToSpeechService +import com.saegil.data.repository.TextToSpeechRepositoryImpl import com.saegil.data.remote.UserInfoService import com.saegil.data.repository.FeedRepositoryImpl import com.saegil.data.repository.MapRepositoryImpl import com.saegil.data.repository.OAuthRepositoryImpl import com.saegil.data.repository.SimulationLogRepositoryImpl import com.saegil.data.repository.ScenarioRepositoryImpl +import com.saegil.domain.repository.TextToSpeechRepository import com.saegil.data.repository.UserInfoRepositoryImpl import com.saegil.domain.repository.FeedRepository import com.saegil.domain.repository.MapRepository @@ -73,6 +77,12 @@ object DataModule { // return AssistantRepositoryImpl(assistantService) // } //todo ktor로 추후 변경하기 위해서 주석처리함 + @Provides + @Singleton + fun provideTextToSpeechRepository(textToSpeechService: TextToSpeechService): TextToSpeechRepository { + return TextToSpeechRepositoryImpl(textToSpeechService) + } + @Provides @Singleton fun provideSimulationLogRepository(simulationLogService: SimulationLogService): SimulationLogRepository { diff --git a/data/src/main/java/com/saegil/data/di/NetworkModule.kt b/data/src/main/java/com/saegil/data/di/NetworkModule.kt index 88dbb6c..339e981 100644 --- a/data/src/main/java/com/saegil/data/di/NetworkModule.kt +++ b/data/src/main/java/com/saegil/data/di/NetworkModule.kt @@ -8,6 +8,7 @@ import com.saegil.data.remote.HttpRoutes.OAUTH_LOGOUT import com.saegil.data.remote.HttpRoutes.OAUTH_VALIDATE_TOKEN import com.saegil.data.remote.HttpRoutes.OAUTH_WITHDRAWAL import com.saegil.data.remote.HttpRoutes.SIMULATION_LOG +import com.saegil.data.remote.HttpRoutes.TTS import com.saegil.data.remote.HttpRoutes.USER import com.saegil.data.remote.MapService import com.saegil.data.remote.MapServiceImpl @@ -17,6 +18,8 @@ import com.saegil.data.remote.ScenarioService import com.saegil.data.remote.ScenarioServiceImpl import com.saegil.data.remote.SimulationLogService import com.saegil.data.remote.SimulationLogServiceImpl +import com.saegil.data.remote.TextToSpeechService +import com.saegil.data.remote.TextToSpeechServiceImpl import com.saegil.data.remote.UserInfoService import com.saegil.data.remote.UserInfoServiceImpl import dagger.Module @@ -66,6 +69,7 @@ object NetworkModule { OAUTH_WITHDRAWAL, OAUTH_VALIDATE_TOKEN, SIMULATION_LOG, + TTS, USER ).any { it in path } } @@ -110,6 +114,12 @@ object NetworkModule { return SimulationLogServiceImpl(client) } + @Provides + @Singleton + fun provideTextToSpeechService(client: HttpClient): TextToSpeechService { + return TextToSpeechServiceImpl(client) + } + @Provides @Singleton fun provideUserInfoService(client: HttpClient): UserInfoService { diff --git a/data/src/main/java/com/saegil/data/remote/HttpRoutes.kt b/data/src/main/java/com/saegil/data/remote/HttpRoutes.kt index 49ac6d2..cabd4d4 100644 --- a/data/src/main/java/com/saegil/data/remote/HttpRoutes.kt +++ b/data/src/main/java/com/saegil/data/remote/HttpRoutes.kt @@ -21,6 +21,8 @@ object HttpRoutes { const val SCENARIO = "$BASE_URL/api/v1/scenarios" //시뮬레이션 상황 목록 조회 const val ASSISTANT = "$BASE_URL/api/v1/llm/assistant/upload" // 음성 파일로부터 Assistant 응답 가져오기 + + const val TTS = "$BASE_URL/api/v1/llm/tts" const val SIMULATION_LOG = "$BASE_URL/api/v1/simulations" diff --git a/data/src/main/java/com/saegil/data/remote/TextToSpeechService.kt b/data/src/main/java/com/saegil/data/remote/TextToSpeechService.kt new file mode 100644 index 0000000..dc2ebd4 --- /dev/null +++ b/data/src/main/java/com/saegil/data/remote/TextToSpeechService.kt @@ -0,0 +1,7 @@ +package com.saegil.data.remote + +import java.io.File + +interface TextToSpeechService { + suspend fun getAssistantAudio(text: String): File +} \ No newline at end of file diff --git a/data/src/main/java/com/saegil/data/remote/TextToSpeechServiceImpl.kt b/data/src/main/java/com/saegil/data/remote/TextToSpeechServiceImpl.kt new file mode 100644 index 0000000..a497ac3 --- /dev/null +++ b/data/src/main/java/com/saegil/data/remote/TextToSpeechServiceImpl.kt @@ -0,0 +1,27 @@ +package com.saegil.data.remote + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.accept +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import javax.inject.Inject + +class TextToSpeechServiceImpl @Inject constructor( + private val client: HttpClient +) : TextToSpeechService { + + override suspend fun getAssistantAudio(text: String): File = withContext(Dispatchers.IO) { + File.createTempFile("assistant_response", ".mp3").apply { + writeBytes(client.post(HttpRoutes.TTS) { + accept(ContentType.Application.OctetStream) + setBody(mapOf("text" to text, "provider" to "OPENAI")) + }.body()) + } + } + +} \ No newline at end of file diff --git a/data/src/main/java/com/saegil/data/repository/TextToSpeechRepositoryImpl.kt b/data/src/main/java/com/saegil/data/repository/TextToSpeechRepositoryImpl.kt new file mode 100644 index 0000000..99bbea4 --- /dev/null +++ b/data/src/main/java/com/saegil/data/repository/TextToSpeechRepositoryImpl.kt @@ -0,0 +1,20 @@ +package com.saegil.data.repository + +import com.saegil.data.remote.TextToSpeechService +import com.saegil.domain.repository.TextToSpeechRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import java.io.File +import javax.inject.Inject + +class TextToSpeechRepositoryImpl @Inject constructor( + private val textToSpeechService: TextToSpeechService +): TextToSpeechRepository { + + override suspend fun downloadAudio(text: String): Flow = flow { + emit(textToSpeechService.getAssistantAudio(text)) + }.flowOn(Dispatchers.IO) + +} \ No newline at end of file diff --git a/domain/src/main/java/com/saegil/domain/repository/TextToSpeechRepository.kt b/domain/src/main/java/com/saegil/domain/repository/TextToSpeechRepository.kt new file mode 100644 index 0000000..773c42b --- /dev/null +++ b/domain/src/main/java/com/saegil/domain/repository/TextToSpeechRepository.kt @@ -0,0 +1,12 @@ +package com.saegil.domain.repository + +import kotlinx.coroutines.flow.Flow +import java.io.File + +interface TextToSpeechRepository { + + suspend fun downloadAudio( + text: String + ): Flow + +} \ No newline at end of file diff --git a/domain/src/main/java/com/saegil/domain/usecase/DownloadAudioUseCase.kt b/domain/src/main/java/com/saegil/domain/usecase/DownloadAudioUseCase.kt new file mode 100644 index 0000000..dadea48 --- /dev/null +++ b/domain/src/main/java/com/saegil/domain/usecase/DownloadAudioUseCase.kt @@ -0,0 +1,14 @@ +package com.saegil.domain.usecase + +import com.saegil.domain.repository.TextToSpeechRepository +import kotlinx.coroutines.flow.Flow +import java.io.File +import javax.inject.Inject + +class DownloadAudioUseCase @Inject constructor( + private val textToSpeechRepository: TextToSpeechRepository +){ + + suspend operator fun invoke(text: String): Flow = textToSpeechRepository.downloadAudio(text) + +} \ No newline at end of file diff --git a/presentation/learning/src/main/java/com/saegil/learning/learning/LearningScreen.kt b/presentation/learning/src/main/java/com/saegil/learning/learning/LearningScreen.kt index fde8fb7..0f13d07 100644 --- a/presentation/learning/src/main/java/com/saegil/learning/learning/LearningScreen.kt +++ b/presentation/learning/src/main/java/com/saegil/learning/learning/LearningScreen.kt @@ -71,7 +71,7 @@ fun LearningScreen( LaunchedEffect(state) { when (state) { - is LearningUiState.isConverting, is LearningUiState.isUploading -> { + is LearningUiState.isUploading -> { while (true) { currentEmotion = CharacterEmotion.NORMAL delay(300) @@ -82,7 +82,6 @@ fun LearningScreen( is LearningUiState.isRecording -> { currentEmotion = CharacterEmotion.WONDER - displayText = "" } is LearningUiState.Idle -> { @@ -97,7 +96,7 @@ fun LearningScreen( is LearningUiState.Error -> { currentEmotion = CharacterEmotion.NORMAL - displayText = "error" + displayText = (state as LearningUiState.Error).message } } } @@ -147,7 +146,7 @@ fun LearningScreen( when (state) { - is LearningUiState.isConverting, is LearningUiState.isUploading -> { + is LearningUiState.isUploading -> { CircularProgressIndicator( modifier = Modifier.padding(top = 100.dp) ) @@ -181,11 +180,8 @@ fun LearningScreen( when (state) { is LearningUiState.Success, is LearningUiState.Idle -> { RecordButton( - isRecording = state == LearningUiState.isRecording, + isRecording = false, onClick = { - if (state == LearningUiState.isRecording) { - viewModel.stopRecording() - } else { if (ContextCompat.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO @@ -195,7 +191,6 @@ fun LearningScreen( } else { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) } - } } ) } @@ -248,6 +243,7 @@ fun RecordButton( ) } + @Composable @Preview(name = "Learning", apiLevel = 33) private fun LearningScreenPreview() { diff --git a/presentation/learning/src/main/java/com/saegil/learning/learning/LearningUiState.kt b/presentation/learning/src/main/java/com/saegil/learning/learning/LearningUiState.kt index 6a6a33d..84ea8a7 100644 --- a/presentation/learning/src/main/java/com/saegil/learning/learning/LearningUiState.kt +++ b/presentation/learning/src/main/java/com/saegil/learning/learning/LearningUiState.kt @@ -8,8 +8,6 @@ sealed interface LearningUiState { data object isRecording : LearningUiState - data object isConverting : LearningUiState - data object isUploading : LearningUiState data class Success(val response: UploadAudio) : LearningUiState diff --git a/presentation/learning/src/main/java/com/saegil/learning/learning/LearningViewModel.kt b/presentation/learning/src/main/java/com/saegil/learning/learning/LearningViewModel.kt index cca92dd..a577a57 100644 --- a/presentation/learning/src/main/java/com/saegil/learning/learning/LearningViewModel.kt +++ b/presentation/learning/src/main/java/com/saegil/learning/learning/LearningViewModel.kt @@ -2,17 +2,20 @@ package com.saegil.learning.learning import android.content.Context import android.content.pm.PackageManager +import android.media.MediaPlayer import android.media.MediaRecorder import android.os.Environment import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.saegil.domain.usecase.DownloadAudioUseCase import com.saegil.domain.usecase.UploadAudioUseCase import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch import java.io.File import java.io.IOException @@ -21,13 +24,15 @@ import javax.inject.Inject @HiltViewModel class LearningViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val uploadAudioUseCase: UploadAudioUseCase + private val uploadAudioUseCase: UploadAudioUseCase, + private val downloadAudioUseCase: DownloadAudioUseCase, ) : ViewModel() { private val _uiState = MutableStateFlow(LearningUiState.Idle) val uiState: StateFlow = _uiState.asStateFlow() private var mediaRecorder: MediaRecorder? = null + private var mediaPlayer: MediaPlayer? = null private var audioFile: File? = null private fun checkAndRequestPermission(): Boolean { @@ -69,16 +74,15 @@ class LearningViewModel @Inject constructor( } mediaRecorder = null _uiState.value = LearningUiState.Idle - convertAndUpload() + exchangeAudio() } catch (e: Exception) { _uiState.value = LearningUiState.Error("녹음 중지 중 오류가 발생했습니다") } } - private fun convertAndUpload() { + private fun exchangeAudio() { viewModelScope.launch { try { - _uiState.value = LearningUiState.isConverting audioFile?.let { file -> _uiState.value = LearningUiState.isUploading @@ -87,7 +91,7 @@ class LearningViewModel @Inject constructor( result .onSuccess { dto -> _uiState.value = LearningUiState.Success(dto) - println("성공: $dto") + downloadAudio(dto.response) } .onFailure { error -> println("실패: ${error.message}") } } @@ -108,4 +112,29 @@ class LearningViewModel @Inject constructor( stopRecording() } } + + private fun downloadAudio(text: String) { + viewModelScope.launch { + try { + downloadAudioUseCase(text) + .catch { + _uiState.value = LearningUiState.Error("오디오 다운로드 실패") + } + .collect { file -> + playAudio(file) + } + } catch (e: Exception) { + _uiState.value = LearningUiState.Error("오디오 처리 중 오류 발생") + } + } + } + + private fun playAudio(file: File) { + mediaPlayer?.release() + mediaPlayer = MediaPlayer().apply { + setDataSource(file.absolutePath) + prepare() + start() + } + } }