Skip to content

Conversation

@wjdrjs00
Copy link
Collaborator

@wjdrjs00 wjdrjs00 commented Jan 12, 2026

Related issue 🛠

Work Description ✏️

  • Quiz에 TTS 기능을 추가했습니다.

Screenshot 📸

  • N/A

Uncompleted Tasks 😅

  • N/A

Summary by CodeRabbit

새로운 기능

  • 음성 읽기 기능 - 퀴즈 질문을 자동으로 음성으로 들을 수 있습니다. 화면 로드 완료 후 질문이 자동 재생되며, 필요시 이미지를 탭하여 다시 청취할 수 있습니다.

  • 스마트 버튼 제어 - 사용자 입력 상태에 따라 진행 버튼이 자동으로 활성화/비활성화되어 더 나은 사용 경험을 제공합니다.

  • 전체 퀴즈 지원 - 모든 퀴즈 유형(주의력, 언어, 기억, 공간-시간, 지속성)에서 음성 기능을 이용할 수 있습니다.

✏️ Tip: You can customize this high-level summary in your review settings.

@wjdrjs00 wjdrjs00 self-assigned this Jan 12, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 12, 2026

Walkthrough

음성 합성(TTS) 기능을 구현하여 퀴즈 화면에 통합합니다. TTS 인터페이스, Android 구현체, 텍스트 정규화기, 의존성 주입을 추가하고, 모든 퀴즈 뷰모델에 TtsManager를 주입한 후 로딩 완료 시 자동 음성 출력과 이미지 클릭 콜백을 연결합니다.

Changes

Cohort / File(s) 변경 사항
TTS 인프라
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/TtsManager.kt, AndroidTtsManager.kt, QuizTextNormalizer.kt, TtsModule.kt
TTS 계약 인터페이스, Android TextToSpeech 기반 구현체, 산술 연산자를 한글로 정규화하는 유틸리티, Hilt 의존성 주입 모듈 신규 추가
어텐션 퀴즈
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/attention/AttentionQuizScreen.kt, AttentionQuizViewModel.kt
TtsManager 주입, speakCurrentQuestion() 메서드 추가, LaunchedEffect로 로딩 완료 후 음성 출력, onImageClick 콜백 연결, onCleared()에서 TTS 정리
언어 퀴즈
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/linguistic/LinguisticQuizScreen.kt, LinguisticQuizViewModel.kt, model/LinguisticQuizUiState.kt
TtsManager 주입, speakCurrentQuestion() 추가, 이미지 클릭 핸들링, isContinueButtonEnabled 계산 속성 신규 추가, checkAnswer() 전에 TTS 중지
기억력 퀴즈
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.kt, MemoryQuizViewModel.kt, component/MemoryQuizTextModeContent.kt, component/MemoryQuizVoiceModeContent.kt, component/MemoryQuizForm.kt
TtsManager 주입, ANSWERING 상태에서 자동 음성 출력, onImageClick 콜백 전파, isContinueEnabled 제어, clickableWithoutRipple 추가
지속성 퀴즈
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/persistence/PersistenceQuizScreen.kt, PersistenceQuizViewModel.kt, component/quizform/PersistenceQuizForm.kt
TtsManager 주입, 로딩 후 speakCurrentQuestion() 자동 실행, onImageClick 콜백 전파, 답변 확인 전 TTS 중지
시공간 퀴즈
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/spacetime/SpaceTimeQuizScreen.kt, SpaceTimeQuizViewModel.kt, component/quizform/SpaceTimeQuizForm.kt
TtsManager 주입, 로딩 완료 후 LaunchedEffect로 음성 출력, onImageClick 콜백 전파, checkAnswer() 전에 TTS 중지
일일 퀀즈
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/daily/DailyQuizScreen.kt, DailyQuizViewModel.kt
TtsManager 주입, 모든 퀴즈 타입별 speakCurrentQuestion() 구현, 로딩 후 음성 출력 자동화, onImageClick 콜백 연결, startListening/checkAnswer 전에 TTS 중지, isTextContinueButtonEnabled 계산 속성 추가
공유 컴포넌트
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt, component/quizform/LinguisticQuizForm.kt
TopQuizDescription에 onImageClick 파라미터 추가, 퀴즈 폼 컴포넌트들에 onImageClick 전파

Sequence Diagram(s)

sequenceDiagram
    participant Screen
    participant ViewModel
    participant LaunchedEffect
    participant TtsManager
    participant AndroidTTS

    Screen->>Screen: Render Quiz Content
    Screen->>LaunchedEffect: Subscribe to currentQuizId + isLoading
    
    Note over LaunchedEffect: Wait for isLoading → false
    
    LaunchedEffect->>ViewModel: speakCurrentQuestion()
    ViewModel->>ViewModel: Get current quiz
    ViewModel->>ViewModel: Normalize text
    ViewModel->>TtsManager: speak(normalizedText)
    TtsManager->>TtsManager: Initialize TextToSpeech if needed
    TtsManager->>AndroidTTS: speak(text)
    AndroidTTS->>AndroidTTS: Queue audio playback
Loading
sequenceDiagram
    participant User
    participant Screen
    participant ViewModel
    participant TtsManager

    User->>Screen: Click Image
    Screen->>ViewModel: onImageClick() callback
    
    alt TTS currently speaking
        ViewModel->>TtsManager: stop()
        TtsManager->>TtsManager: Halt playback
    end
    
    ViewModel->>TtsManager: speak(question)
    TtsManager->>TtsManager: Play audio
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 소곤거리는 목소리로 퀴즈가 속삭여
음성 합성의 마법이 화면을 춤추게 해
로딩 끝나고 울려 퍼지는 한국말
이미지를 누르면 다시 들려오니
귀로 듣는 퀴즈, 얼마나 신기로운가! 🎉

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive PR 설명이 템플릿 구조를 따르고 있으나, Work Description 섹션의 내용이 매우 간략하고 구체적인 설명이 부족합니다. Work Description에 구현된 TTS 기능의 구체적인 내용(AndroidTtsManager, QuizTextNormalizer, 각 quiz 화면 통합 등)을 추가하여 변경사항을 더욱 명확히 기술해주세요.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 TTS 기능 추가라는 주요 변경 내용을 명확하게 반영하고 있으며, 간결하고 구체적입니다.
Linked Issues check ✅ Passed PR은 #71 이슈의 모든 요구사항을 충족합니다: TTS 기능 구현(TtsManager, AndroidTtsManager, QuizTextNormalizer), 퀴즈 진입 시 자동 발화 구현(LaunchedEffect 기반), 모든 quiz 유형에 통합 완료.
Out of Scope Changes check ✅ Passed 모든 변경사항이 TTS 기능 추가와 직접적으로 관련되어 있으며, 범위를 벗어난 변경은 없습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/daily/DailyQuizViewModel.kt (1)

261-264: onCleared()에서 ttsManager.destroy() 호출이 누락되었습니다.

MemoryQuizViewModel과 달리 DailyQuizViewModelonCleared()에서 ttsManager.destroy()가 호출되지 않아 TTS 리소스가 해제되지 않을 수 있습니다.

🔧 수정 제안
     override fun onCleared() {
         super.onCleared()
         sttManager.destroy()
+        ttsManager.destroy()
     }
🤖 Fix all issues with AI agents
In
@feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/TtsManager.kt:
- Around line 3-8: The current TtsManager.destroy() design lets multiple
ViewModels call AndroidTtsManager.destroy()/tts?.shutdown(), causing premature
shutdowns and repeated init/shutdown cycles; change the lifecycle management so
singleton TTS is only shutdown at app level: either remove destroy() from the
TtsManager interface and make AndroidTtsManager.shutdown()/tts?.shutdown()
private and invoked from an Application-level cleanup (e.g.,
Application.onTerminate or a LifecycleObserver via ProcessLifecycleOwner), or
replace destroy() with explicit acquire()/release() reference-counting methods
on TtsManager and have each ViewModel call release() (not direct shutdown);
update all ViewModels (SpaceTimeQuizViewModel, PersistenceQuizViewModel,
MemoryQuizViewModel, LinguisticQuizViewModel, AttentionQuizViewModel,
DailyQuizViewModel) to follow the chosen pattern so cleanup is centralized and
consistent, and remove direct calls to AndroidTtsManager.initEngine()/shutdown()
from ViewModels.

In
@feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/TtsModule.kt:
- Around line 9-15: AndroidTtsManager currently stores only a single pendingTask
so multiple speak() calls during initialization (isReady == false) overwrite
pendingTask and drop earlier requests; change pendingTask into a queue (e.g., a
FIFO list) or buffer all incoming speak() requests when isReady is false,
enqueue each call from speak(), and on initialization completion (or after
destroy() reinitializes) drain the queue and invoke speak for each entry in
order; update references to pendingTask in AndroidTtsManager, ensure destroy()
resets/clears the queue appropriately, and preserve existing behavior when
isReady == true.
🧹 Nitpick comments (5)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/QuizTextNormalizer.kt (1)

4-12: 음수 및 공백 처리 개선 고려

현재 구현은 기본적인 산술 표현식에는 잘 동작하지만, 몇 가지 엣지 케이스가 있습니다:

  • 음수 표현: "-5"" 빼기 5" (의도치 않은 결과)
  • 기존 공백: "5 - 3""5 빼기 3" (이중 공백)

퀴즈 문맥에서 이런 케이스가 드물다면 현재 구현도 괜찮지만, 필요시 정규식으로 개선할 수 있습니다.

♻️ 선택적 개선안
 object QuizTextNormalizer {
     fun normalizeExpression(expression: String): String {
         return expression
-            .replace("-", " 빼기 ")
-            .replace("+", " 더하기 ")
-            .replace("*", " 곱하기 ")
-            .replace("/", " 나누기 ")
-            .replace("=", " 은(는) ")
-            .trim()
+            .replace(Regex("(?<=\\d)\\s*-\\s*(?=\\d)"), " 빼기 ")
+            .replace(Regex("(?<=\\d)\\s*\\+\\s*(?=\\d)"), " 더하기 ")
+            .replace(Regex("(?<=\\d)\\s*\\*\\s*(?=\\d)"), " 곱하기 ")
+            .replace(Regex("(?<=\\d)\\s*/\\s*(?=\\d)"), " 나누기 ")
+            .replace(Regex("\\s*=\\s*"), " 은(는) ")
+            .trim()
     }
 }
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/linguistic/LinguisticQuizViewModel.kt (1)

42-45: 하드코딩된 TTS 텍스트를 문자열 리소스로 추출하는 것을 고려해 보세요.

현재 "아래의 그림은 무엇일까요?"가 코드에 직접 작성되어 있습니다. 다국어 지원이나 텍스트 변경 시 유지보수가 어려울 수 있습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/AndroidTtsManager.kt (1)

25-36: onInit에서 언어 설정 실패 시 사용자에게 피드백이 없습니다.

LANG_MISSING_DATA 또는 LANG_NOT_SUPPORTED 상태일 때 isReadyfalse로 유지되어 TTS가 작동하지 않지만, 사용자나 호출자에게 이 상태를 알리는 방법이 없습니다. 로깅을 추가하거나 콜백/상태를 통해 초기화 실패를 알리는 것을 고려해 보세요.

🔧 로깅 추가 제안
 override fun onInit(status: Int) {
     if (status == TextToSpeech.SUCCESS) {
         val result = tts?.setLanguage(Locale.KOREAN)
         if (result != TextToSpeech.LANG_MISSING_DATA && result != TextToSpeech.LANG_NOT_SUPPORTED) {
             isReady = true
             pendingTask?.invoke()
             pendingTask = null
+        } else {
+            Timber.w("TTS 한국어 언어팩이 지원되지 않거나 누락되었습니다: result=$result")
         }
     } else {
+        Timber.e("TTS 초기화 실패: status=$status")
         tts = null
     }
 }
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt (1)

29-29: 포매팅: = 앞에 공백이 누락되었습니다.

코드 스타일 일관성을 위해 = 앞에 공백을 추가해 주세요.

🔧 수정 제안
-    onImageClick: () -> Unit= {},
+    onImageClick: () -> Unit = {},
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizVoiceModeContent.kt (1)

57-62: 일관성 문제: onImageClickTopQuizDescription에 직접 전달하세요.

TopQuizDescription은 이미 onImageClick 파라미터를 받아 내부 이미지에 클릭 핸들러를 적용합니다. 현재 구현은 modifierclickableWithoutRipple을 적용하여 전체 영역을 클릭 가능하게 만들었지만, 이는 다른 화면들과 일관성이 없습니다.

♻️ 수정 제안
         TopQuizDescription(
             quizDescription = "방금 나온 단어를\n순서대로 말씀해주세요!",
-            modifier = Modifier
-                .padding(horizontal = 20.dp)
-                .clickableWithoutRipple(onClick = onImageClick, role = Role.Button)
+            onImageClick = onImageClick,
+            modifier = Modifier.padding(horizontal = 20.dp)
         )
📜 Review details

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6af8d97 and 5f7f75c.

📒 Files selected for processing (24)
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/attention/AttentionQuizScreen.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/attention/AttentionQuizViewModel.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/LinguisticQuizForm.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/MemoryQuizForm.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/PersistenceQuizForm.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/SpaceTimeQuizForm.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/daily/DailyQuizScreen.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/daily/DailyQuizViewModel.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/linguistic/LinguisticQuizScreen.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/linguistic/LinguisticQuizViewModel.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/linguistic/model/LinguisticQuizUiState.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizTextModeContent.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizVoiceModeContent.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/persistence/PersistenceQuizScreen.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/persistence/PersistenceQuizViewModel.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/spacetime/SpaceTimeQuizScreen.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/spacetime/SpaceTimeQuizViewModel.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/AndroidTtsManager.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/QuizTextNormalizer.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/TtsManager.kt
  • feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/TtsModule.kt
🧰 Additional context used
🧬 Code graph analysis (8)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/AndroidTtsManager.kt (1)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/TtsManager.kt (1)
  • speak (5-5)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/attention/AttentionQuizScreen.kt (1)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizLoadContent.kt (1)
  • QuizLoadContent (33-87)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/LinguisticQuizForm.kt (1)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt (1)
  • CenterQuizDescription (62-96)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/PersistenceQuizForm.kt (1)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt (1)
  • TopQuizDescription (26-60)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.kt (1)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizLoadContent.kt (1)
  • QuizLoadContent (33-87)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/SpaceTimeQuizForm.kt (1)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt (1)
  • CenterQuizDescription (62-96)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/linguistic/LinguisticQuizScreen.kt (2)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizLoadContent.kt (1)
  • QuizLoadContent (33-87)
core/designsystem/src/main/kotlin/com/moa/app/designsystem/component/core/button/MaButton.kt (1)
  • MaButton (30-68)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/MemoryQuizForm.kt (1)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizTextModeContent.kt (1)
  • MemoryQuizTextModeContent (26-91)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Run Unit Tests
🔇 Additional comments (49)
feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizTextModeContent.kt (4)

30-31: LGTM!

TTS 기능 지원을 위한 isContinueEnabledonImageClick 파라미터 추가가 적절합니다. 파라미터 명명이 명확하고 용도가 잘 드러납니다.


43-46: LGTM!

onImageClick 콜백을 CenterQuizDescription에 전달하여 이미지 클릭 시 TTS가 실행될 수 있도록 올바르게 연결되었습니다.


77-79: LGTM!

MaButtonenabled 상태를 외부에서 제어할 수 있도록 변경하여 입력 유효성에 따른 버튼 활성화/비활성화가 가능해졌습니다.


96-102: LGTM!

Preview 함수가 새로운 파라미터들로 올바르게 업데이트되었습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/MemoryQuizForm.kt (4)

20-23: LGTM!

isTextContinueButtonEnabledonImageClick 파라미터가 추가되어 TTS 기능과 텍스트 모드 버튼 상태 제어가 가능해졌습니다. 파라미터 위치도 관련 콜백들과 논리적으로 그룹화되어 있습니다.


44-52: LGTM!

VOICE 모드에서 onImageClick 콜백이 올바르게 전달되어 음성 입력 모드에서도 TTS 재생이 가능합니다.


55-62: LGTM!

TEXT 모드에서 MemoryQuizTextModeContent에 모든 필요한 파라미터가 올바르게 전달되었습니다. isTextContinueButtonEnabledisContinueEnabled 매핑이 명확합니다.


71-88: LGTM!

Preview 함수가 새로운 파라미터들을 포함하여 올바르게 업데이트되었습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/attention/AttentionQuizViewModel.kt (5)

86-86: TTS 중지 로직 적절함

답변 확인 전 진행 중인 TTS를 중지하는 것은 올바른 UX 처리입니다.


109-125: 다음 문제 이동 시 TTS 자동 재생 누락 가능성

goToNextQuestion()에서 currentQuestionIndex가 업데이트된 후 새 문제에 대한 TTS가 자동으로 시작되지 않습니다. UI 레이어에서 상태 변화를 감지하여 처리한다면 문제없으나, 그렇지 않다면 사용자가 수동으로 재생해야 합니다.


161-164: TtsManager 리소스 정리 적절함

onCleared()에서 ttsManager.destroy()를 호출하여 ViewModel 소멸 시 TTS 리소스를 올바르게 해제합니다.


30-35: TtsManager DI 주입 적절함

Hilt를 통한 TtsManager 생성자 주입이 올바르게 구현되었습니다.


44-48: 자동 재생 로직이 올바르게 구현되어 있습니다

speakCurrentQuestion() 함수는 UI 레이어(AttentionQuizScreen)에서 자동으로 호출됩니다. LaunchedEffect를 통해 currentQuizId 변경을 감지하고, 로딩이 완료될 때까지 대기한 후(isLoading == false) 자동으로 발화를 재생하므로 PR 목표인 "퀴즈 진입 시 TTS 발화(자동 재생)"가 올바르게 구현되었습니다.

Likely an incorrect or invalid review comment.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizScreen.kt (3)

44-51: inputMode 변경 시 TTS가 재실행될 수 있습니다.

snapshotFlow에서 TripleinputMode를 포함하고 있지만, filter 조건에서는 사용하지 않습니다. distinctUntilChanged()Triple 전체를 비교하므로, 사용자가 음성 모드에서 텍스트 모드로 전환할 때 TTS가 다시 실행됩니다.

의도된 동작이라면 무시해도 됩니다. 그렇지 않다면 inputMode를 제외하는 것을 고려해주세요:

♻️ 제안된 수정
     snapshotFlow {
-        Triple(uiState.quizState, uiState.inputMode, uiState.isLoading)
+        uiState.quizState to uiState.isLoading
     }
-        .filter { (quizState, _, isLoading) ->
+        .filter { (quizState, isLoading) ->
             quizState == MemoryQuizSetState.ANSWERING && !isLoading
         }

60-66: LGTM!

onImagesFinishedonImageClick 콜백이 ViewModel 메서드에 올바르게 연결되었습니다. 이미지 클릭 시 수동 TTS 재생 기능이 적절히 구현되었습니다.


140-158: LGTM!

ANSWERING 상태에서 InputMode에 따른 분기 처리가 명확하고, onImageClickisContinueEnabled가 각각의 모드 컴포넌트에 올바르게 전달되고 있습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/linguistic/model/LinguisticQuizUiState.kt (1)

30-31: LGTM!

selectedAnswerIndex의 null 여부를 기반으로 버튼 활성화 상태를 반환하는 깔끔한 파생 상태입니다. 기존 computed property 패턴과 일관성 있게 구현되었습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/SpaceTimeQuizForm.kt (1)

35-42: LGTM!

onImageClick 콜백이 CenterQuizDescription에 올바르게 전달되고 있습니다. 다른 퀴즈 폼 컴포넌트들과 일관된 패턴입니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/LinguisticQuizForm.kt (1)

36-42: LGTM!

onImageClick 파라미터가 올바르게 추가되고 CenterQuizDescription에 전달되었습니다. TTS 트리거를 위한 일관된 패턴입니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/quizform/PersistenceQuizForm.kt (1)

25-31: LGTM!

onImageClick 파라미터가 TopQuizDescription에 올바르게 전달되고 있습니다. 다른 퀴즈 폼들과 일관된 TTS 트리거 패턴을 따르고 있습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/linguistic/LinguisticQuizViewModel.kt (3)

28-33: TtsManager 의존성 주입이 적절합니다.

Hilt를 통한 TtsManager 주입과 생성자 매개변수 추가가 올바르게 구현되었습니다.


78-79: TTS 중지 로직이 적절합니다.

사용자가 답변을 확인할 때 진행 중인 TTS를 중지하는 것은 좋은 UX입니다.


147-150: onCleared()에서 TtsManager 리소스 정리가 올바르게 구현되었습니다.

ViewModel이 소멸될 때 TTS 리소스를 적절히 해제하여 메모리 누수를 방지합니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/spacetime/SpaceTimeQuizViewModel.kt (2)

27-33: TTS 통합 패턴이 다른 ViewModel과 일관성 있게 구현되었습니다.

LinguisticQuizViewModel과 동일한 패턴으로 TtsManager가 주입되어 코드베이스 전반에 걸쳐 일관된 구조를 유지합니다.


42-45: TTS 발화 및 생명주기 관리가 적절합니다.

speakCurrentQuestion()과 onCleared()에서의 리소스 정리가 올바르게 구현되었습니다.

Also applies to: 147-150

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/attention/AttentionQuizScreen.kt (2)

45-54: LaunchedEffect를 사용한 TTS 자동 재생 구현이 적절합니다.

snapshotFlowfirst()를 조합하여 로딩 완료 시점을 감지하고 TTS를 트리거하는 패턴이 올바르게 구현되었습니다. currentQuizId를 키로 사용하여 퀴즈가 변경될 때마다 새로운 발화를 시작합니다.


59-66: onImageClick 콜백 연결이 올바릅니다.

이미지 클릭 시 speakCurrentQuestion을 호출하여 사용자가 질문을 다시 들을 수 있도록 구현되었습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/linguistic/LinguisticQuizScreen.kt (2)

52-61: 다른 퀴즈 화면과 일관된 TTS 자동 재생 패턴입니다.

AttentionQuizScreen과 동일한 LaunchedEffect + snapshotFlow 패턴을 사용하여 코드베이스 전반에 걸쳐 일관성을 유지합니다.


134-137: 버튼 활성화 로직 개선이 적절합니다.

uiState.isContinueButtonEnabled를 사용하여 버튼 상태를 관리하는 것은 상태 관리를 UI 상태 클래스에 위임하는 좋은 패턴입니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/AndroidTtsManager.kt (3)

9-15: AndroidTtsManager 클래스 구조가 적절합니다.

TtsManager 인터페이스와 TextToSpeech.OnInitListener를 구현하여 Android TTS 기능을 캡슐화했습니다. 지연 초기화 패턴을 사용하여 필요할 때만 TTS 엔진을 초기화합니다.


38-50: speak() 메서드의 지연 실행 패턴이 적절합니다.

TTS가 준비되지 않았을 때 pendingTask에 저장하여 초기화 완료 후 실행하는 패턴이 올바르게 구현되었습니다. isFlush 파라미터를 통해 큐 모드를 제어할 수 있어 유연합니다.


57-63: destroy() 메서드에서 리소스 정리가 완전합니다.

stop(), shutdown() 호출 후 모든 상태를 초기화하여 메모리 누수와 리소스 누수를 방지합니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/spacetime/SpaceTimeQuizScreen.kt (2)

52-61: 일관된 TTS 자동 재생 패턴이 적용되었습니다.

다른 퀴즈 화면(Attention, Linguistic)과 동일한 LaunchedEffect + snapshotFlow 패턴을 사용합니다.


94-101: SpaceTimeQuizContent에 onImageClick이 올바르게 전파됩니다.

컴포저블 계층 구조를 통해 이미지 클릭 콜백이 적절히 전달됩니다.

Also applies to: 126-132

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/persistence/PersistenceQuizViewModel.kt (4)

44-47: 동적 질문 텍스트를 사용하는 좋은 패턴입니다.

다른 ViewModel(Linguistic, SpaceTime)에서 하드코딩된 텍스트를 사용하는 것과 달리, currentQuiz.questionContent를 직접 사용하여 각 퀴즈의 실제 질문을 읽어줍니다. 이 패턴이 다른 ViewModel에도 적용되면 좋겠습니다.


80-81: checkAnswer()에서 TTS 중지 로직이 일관되게 구현되었습니다.

다른 ViewModel과 동일한 패턴으로 답변 확인 시 진행 중인 TTS를 중지합니다.


130-134: onSuccess 핸들러에서 isLoading 상태 업데이트가 추가되었습니다.

성공 시에도 isLoading = false를 설정하여 상태 관리가 더 명확해졌습니다.


153-156: onCleared()에서 TtsManager 정리가 올바르게 구현되었습니다.

ViewModel 생명주기 종료 시 TTS 리소스를 적절히 해제합니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/component/QuizDescription.kt (1)

51-58: LGTM!

이미지에 clickableWithoutRipple을 적용하고 Role.Button을 설정하여 접근성을 고려한 구현이 적절합니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/persistence/PersistenceQuizScreen.kt (2)

44-53: LGTM!

LaunchedEffectsnapshotFlow를 사용하여 로딩 완료 후 TTS를 트리거하는 패턴이 적절합니다. currentQuizId가 변경될 때마다 새로운 effect가 시작되고, .first()를 사용하여 로딩이 완료된 후 한 번만 음성을 출력합니다.


86-122: LGTM!

onImageClick 콜백이 PersistenceQuizContent에서 PersistenceQuizForm까지 올바르게 전달되고 있습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/MemoryQuizViewModel.kt (3)

60-68: LGTM!

speakCurrentQuestion() 구현이 입력 모드에 따라 적절한 안내 문구를 선택하여 TTS로 출력합니다.


100-103: LGTM!

STT 시작 전에 TTS를 중지하여 음성 입력과 출력 간의 충돌을 방지하는 좋은 패턴입니다.


234-238: LGTM!

onCleared()에서 ttsManager.destroy()를 호출하여 리소스를 적절히 정리합니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/memory/component/MemoryQuizVoiceModeContent.kt (1)

194-205: LGTM!

Preview가 새로운 onImageClick 파라미터를 포함하도록 업데이트되었습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/daily/DailyQuizScreen.kt (2)

51-65: 모드 변경 시 TTS가 다시 트리거될 수 있습니다.

snapshotFlowmemoryQuizInputMode를 포함하고 .collect를 사용하므로, MemoryQuiz에서 음성 모드와 텍스트 모드 간 전환 시 speakCurrentQuestion()이 다시 호출됩니다. 이것이 의도된 동작인지 확인해 주세요.

만약 의도된 동작이 아니라면, 모드 전환 시에는 TTS를 트리거하지 않도록 수정이 필요합니다.


106-212: LGTM!

모든 퀴즈 폼 컴포넌트(PersistenceQuizForm, LinguisticQuizForm, MemoryQuizForm, AttentionQuizForm, SpaceTimeQuizForm)에 onImageClick 콜백이 일관되게 전달되고 있습니다.

feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/daily/DailyQuizViewModel.kt (2)

65-85: LGTM!

speakCurrentQuestion()이 모든 퀴즈 유형을 적절히 처리하고, 로딩 중이거나 결과 다이얼로그 표시 중에는 TTS를 실행하지 않도록 가드 조건이 잘 설정되어 있습니다.


137-140: LGTM!

STT 시작 전 TTS 중지 패턴이 MemoryQuizViewModel과 일관되게 적용되었습니다.

Comment on lines +3 to +8
interface TtsManager {
val isSpeaking: Boolean
fun speak(text: String, isFlush: Boolean = true)
fun stop()
fun destroy()
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how destroy() is called across ViewModels
rg -n "ttsManager\.destroy\(\)" --type kotlin

Repository: team-memory-care/MoA-Android

Length of output: 731


🏁 Script executed:

#!/bin/bash
# Also search for TtsModule binding configuration
rg -n "TtsManager" --type kotlin | head -30

Repository: team-memory-care/MoA-Android

Length of output: 2373


🏁 Script executed:

#!/bin/bash
# Check for any @HiltViewModel annotations and onCleared implementations
rg -n "onCleared\|@HiltViewModel\|SingletonComponent" --type kotlin | grep -E "(onCleared|HiltViewModel|SingletonComponent)" | head -20

Repository: team-memory-care/MoA-Android

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Check TtsModule scope annotation
cat feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/TtsModule.kt

Repository: team-memory-care/MoA-Android

Length of output: 437


🏁 Script executed:

#!/bin/bash
# Check one ViewModel's onCleared implementation to see context
sed -n '145,155p' feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/spacetime/SpaceTimeQuizViewModel.kt

Repository: team-memory-care/MoA-Android

Length of output: 213


🏁 Script executed:

#!/bin/bash
# Check AndroidTtsManager destroy() implementation
cat feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/AndroidTtsManager.kt | head -50

Repository: team-memory-care/MoA-Android

Length of output: 1571


🏁 Script executed:

#!/bin/bash
# Get the full AndroidTtsManager including destroy() method
cat feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/AndroidTtsManager.kt

Repository: team-memory-care/MoA-Android

Length of output: 1803


🏁 Script executed:

#!/bin/bash
# Check if all ViewModels using TtsManager call destroy() - particularly DailyQuizViewModel
for vm in feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/*/\*ViewModel.kt; do
  if [ -f "$vm" ]; then
    if grep -q "private val ttsManager: TtsManager" "$vm"; then
      echo "=== $(basename $vm) ==="
      if grep -q "ttsManager.destroy()" "$vm"; then
        echo "✓ Calls destroy()"
      else
        echo "✗ Does NOT call destroy()"
      fi
    fi
  fi
done

Repository: team-memory-care/MoA-Android

Length of output: 54


🏁 Script executed:

#!/bin/bash
# Find all ViewModels that use TtsManager
rg -l "private val ttsManager: TtsManager" --type kotlin

Repository: team-memory-care/MoA-Android

Length of output: 657


🏁 Script executed:

#!/bin/bash
# For each ViewModel, check if it calls destroy()
rg -A 10 "override fun onCleared" feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz --type kotlin

Repository: team-memory-care/MoA-Android

Length of output: 5856


Singleton 스코프의 destroy() 호출 구조에 실제 문제가 있습니다.

이 인터페이스 설계 자체는 깔끔하지만, 구현 레벨에서 심각한 생명주기 문제가 발생합니다. AndroidTtsManager.destroy()tts?.shutdown()을 호출하는데, 현재 5개 ViewModel이 모두 onCleared()에서 이를 호출하고 있습니다:

  • SpaceTimeQuizViewModel, PersistenceQuizViewModel, MemoryQuizViewModel, LinguisticQuizViewModel, AttentionQuizViewModel에서 destroy() 호출

문제점:

  1. ViewModel1이 종료되면 destroy()가 TextToSpeech 엔진을 완전히 shutdown
  2. 다른 화면에서 TTS가 필요하면 다시 initEngine()으로 생성 → 반복되는 init/shutdown 사이클
  3. TextToSpeech 자원 누수 및 성능 저하

또한 DailyQuizViewModel은 ttsManager를 주입받았는데 destroy()를 호출하지 않으므로 일관성이 없습니다.

해결안: Singleton 자원은 destroy()를 여러 ViewModel에서 호출하지 않도록 설계를 변경하거나, Application 레벨에서만 cleanup하도록 관리해야 합니다.

🤖 Prompt for AI Agents
In
@feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/TtsManager.kt
around lines 3 - 8, The current TtsManager.destroy() design lets multiple
ViewModels call AndroidTtsManager.destroy()/tts?.shutdown(), causing premature
shutdowns and repeated init/shutdown cycles; change the lifecycle management so
singleton TTS is only shutdown at app level: either remove destroy() from the
TtsManager interface and make AndroidTtsManager.shutdown()/tts?.shutdown()
private and invoked from an Application-level cleanup (e.g.,
Application.onTerminate or a LifecycleObserver via ProcessLifecycleOwner), or
replace destroy() with explicit acquire()/release() reference-counting methods
on TtsManager and have each ViewModel call release() (not direct shutdown);
update all ViewModels (SpaceTimeQuizViewModel, PersistenceQuizViewModel,
MemoryQuizViewModel, LinguisticQuizViewModel, AttentionQuizViewModel,
DailyQuizViewModel) to follow the chosen pattern so cleanup is centralized and
consistent, and remove direct calls to AndroidTtsManager.initEngine()/shutdown()
from ViewModels.

Comment on lines +9 to +15
@Module
@InstallIn(SingletonComponent::class)
abstract class TtsModule {

@Binds
@Singleton
abstract fun bindTtsManager(impl: AndroidTtsManager): TtsManager
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and examine AndroidTtsManager implementation
fd "AndroidTtsManager" --type f

Repository: team-memory-care/MoA-Android

Length of output: 159


🏁 Script executed:

cat feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/AndroidTtsManager.kt

Repository: team-memory-care/MoA-Android

Length of output: 1803


🏁 Script executed:

# Check for speak() call patterns and usage of TtsManager
rg "\.speak\(" feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/ -A 2 -B 2

Repository: team-memory-care/MoA-Android

Length of output: 679


🏁 Script executed:

# Search for TtsManager usage across the entire repository
rg "TtsManager" --type kt -B 2 -A 5 | head -100

Repository: team-memory-care/MoA-Android

Length of output: 98


🏁 Script executed:

# Search for TtsManager usage - using pattern matching without file type
rg "TtsManager" --include="*.kt" | head -50

Repository: team-memory-care/MoA-Android

Length of output: 513


🏁 Script executed:

# Search for TtsManager usage with correct rg syntax
rg "TtsManager" -g "*.kt"

Repository: team-memory-care/MoA-Android

Length of output: 2327


🏁 Script executed:

# Check how ttsManager.speak() is actually called in one of the ViewModels
rg "ttsManager\.speak" feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/ -B 3 -A 1

Repository: team-memory-care/MoA-Android

Length of output: 3842


pendingTask 덮어쓰기로 인한 음성 출력 손실 버그 있음

destroy()speak() 재호출 시 적절히 재초기화되는 점은 확인되었습니다. 다만 AndroidTtsManager에서 초기화 중(isReady=false)에 여러 speak() 호출이 연속으로 들어올 경우, 각 호출이 pendingTask를 덮어써서 이전 요청들이 손실되는 버그가 있습니다.

예: speak("A")speak("B")speak("C") 순서로 호출되면, "A"와 "B"는 무시되고 "C"만 실행됩니다.

pendingTask를 큐 형태로 관리하거나, 초기화 중 들어오는 요청들을 모두 처리할 수 있도록 수정이 필요합니다.

🤖 Prompt for AI Agents
In
@feature/senior/src/main/kotlin/com/moa/app/feature/senior/quiz/tts/TtsModule.kt
around lines 9 - 15, AndroidTtsManager currently stores only a single
pendingTask so multiple speak() calls during initialization (isReady == false)
overwrite pendingTask and drop earlier requests; change pendingTask into a queue
(e.g., a FIFO list) or buffer all incoming speak() requests when isReady is
false, enqueue each call from speak(), and on initialization completion (or
after destroy() reinitializes) drain the queue and invoke speak for each entry
in order; update references to pendingTask in AndroidTtsManager, ensure
destroy() resets/clears the queue appropriately, and preserve existing behavior
when isReady == true.

@wjdrjs00 wjdrjs00 merged commit 563ff79 into develop Jan 12, 2026
4 checks passed
@wjdrjs00 wjdrjs00 deleted the feature/71-tts branch January 12, 2026 16:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] TTS 기능을 구현합니다.

2 participants