Skip to content

Commit

Permalink
added TTS Manager to ResultFragment
Browse files Browse the repository at this point in the history
  • Loading branch information
moon0900 committed Dec 15, 2024
1 parent c53fe8e commit 9699e8b
Show file tree
Hide file tree
Showing 3 changed files with 203 additions and 2 deletions.
4 changes: 4 additions & 0 deletions app/src/main/java/com/dna/beyoureyes/model/UserInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,10 @@ class UserInfo (
}
}

fun findMatchingAllergy(foodAllergy: Set<String>) : Set<String> {
return allergic.intersect(foodAllergy)
}

companion object {

fun getAge(birth: Timestamp) : Int {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import com.dna.beyoureyes.R
import com.dna.beyoureyes.databinding.FragmentResultBinding
import com.dna.beyoureyes.ui.CustomToolbar
Expand All @@ -20,13 +19,16 @@ import com.dna.beyoureyes.ui.foodDetail.ResultFailFragment
import com.dna.beyoureyes.ui.foodDetail.ResultKcalFragment
import com.dna.beyoureyes.ui.foodDetail.ResultNutriBarFragment
import com.dna.beyoureyes.ui.foodDetail.ResultNutriPieFragment
import com.dna.beyoureyes.util.TTSManager
import java.util.concurrent.atomic.AtomicBoolean


class FoodResultFragment: Fragment() {
private var _binding: FragmentResultBinding? = null
private val binding get() = _binding!!
private val viewModel: FoodViewModel by activityViewModels()
private val resultFragments = mutableListOf<Fragment>()
private val ttsManager by lazy { TTSManager.getInstance(requireContext()) } // 싱글톤

override fun onCreateView(
inflater: LayoutInflater,
Expand Down Expand Up @@ -105,8 +107,40 @@ class FoodResultFragment: Fragment() {
Log.d("Result", "알레르기 인식 실패")
}

// TTS 재생 상태 변경 리스너 설정 - UI 업뎃을 위한 콜백 정의
binding.resultButtonVoice // 버튼 초기화
ttsManager.setTTSStateListener(object: TTSManager.TTSStateListener {
override fun onTTSStarted() {
binding.resultButtonVoice.text = "재생 중 / ■"
}
override fun onTTSDone() {
binding.resultButtonVoice.text = "다시 듣기 / ▶"
}
})


// 읽어 주기 버튼 (이 뷰에선 데이터 갱신될 일 없으니 observe가 아닌 초기에 한 번만 값을 읽기
val ttsToastShowed = AtomicBoolean(false) // 일회용(최초 한번만 보여주기)
viewModel.foodData.value?.let { food ->
binding.resultButtonVoice.setOnClickListener {
if (ttsManager.isSpeaking()) {
ttsManager.stop()
} else {
ttsManager.speakNutritionalInfo(food)
if (ttsToastShowed.compareAndSet(false, true)) {
Toast.makeText(
requireContext(),
"재생을 멈추려면 버튼을 다시 눌러주세요.",
Toast.LENGTH_SHORT
).show()
}
}
}
}

// 먹기 버튼
binding.resultButtonEat.setOnClickListener {
ttsManager.stop()
findNavController().navigate(R.id.food_navi_eat)
}

Expand All @@ -118,4 +152,9 @@ class FoodResultFragment: Fragment() {
_binding = null
}

override fun onDestroy() {
super.onDestroy()
ttsManager.shutdown() // 엔진 종료
}

}
158 changes: 158 additions & 0 deletions app/src/main/java/com/dna/beyoureyes/util/TTSManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package com.dna.beyoureyes.util

import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import android.util.Log
import com.dna.beyoureyes.AppUser
import com.dna.beyoureyes.model.CaloricNutrient
import com.dna.beyoureyes.model.Food
import java.util.LinkedList
import java.util.Locale

class TTSManager(context: Context) :
TextToSpeech.OnInitListener, UtteranceProgressListener() {

companion object {
@Volatile
private var INSTANCE: TTSManager? = null // 싱글톤 패턴 구현(TTS 엔진이 무거워서 최적화를 위해)

fun getInstance(context: Context): TTSManager {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: TTSManager(context).also { INSTANCE = it }
}
}
}

private var tts: TextToSpeech? = null // tts 객체
private var isSpeaking: Boolean = false // 재생과 일시 정지 전환을 위한 Boolean
private val utteranceIdQueue = LinkedList<String>() // 음성 출력 큐 관리
private var ttsStateListener: TTSStateListener? = null // TTS 상태 변경 콜백 인터페이스

// TTS 상태 변경 콜백 인터페이스 -> UI 실시간 업뎃을 위해
interface TTSStateListener {
fun onTTSStarted() // TTS 음성 시작 시
fun onTTSDone() // TTS 음성 종료 시
}

init {
tts = TextToSpeech(context, this).apply {
setOnUtteranceProgressListener(this@TTSManager) // UtteranceProgressListener 등록
} // 초기화
}

fun setTTSStateListener(listener: TTSStateListener) { // TTS 상태 변경 콜백 설정 함수
this.ttsStateListener = listener
}

override fun onInit(status: Int) { // 초기화 직후 처리
if (status == TextToSpeech.SUCCESS) { // TTS 초기화 성공 시
val result = tts?.setLanguage(Locale.KOREAN) // 한국어 설정
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
Log.e("TTSManager", "Language is not supported or missing data")
} else {
Log.d("TTSManager", "TextToSpeech initialization successful") // 언어 설정 성공
}
} else { // TTS 초기화 실패 시
Log.e("TTSManager", "TextToSpeech initialization failed")
}
}

private fun speak(text: String) {
if (tts?.isSpeaking == true) {
return // 이전 음성 출력이 완료될 때까지 대기
}
isSpeaking = true
ttsStateListener?.onTTSStarted() // 상태 변경 - 음성 출력 시작

val utteranceId = System.currentTimeMillis().toString()
utteranceIdQueue.add(utteranceId)

val params = Bundle()
params.putString(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId)
tts?.speak(text, TextToSpeech.QUEUE_ADD, params, utteranceId)
}

fun stop() {
tts?.stop()
isSpeaking = false
utteranceIdQueue.clear()
ttsStateListener?.onTTSDone()
}

fun isSpeaking(): Boolean {
return isSpeaking
}

fun speakNutritionalInfo(food: Food) {
val calorieMsg: String? = food.kcal?.let {"해당 식품의 칼로리는 $it kcal 입니다. "}

val allergyMsg: String? = food.allergy?.let {
"해당 식품에는 ${it.joinToString(", ")} 성분이 함유되어 있습니다. "
}
val commonAllergyMsg: String? = food.allergy?.let { foodAllergy ->
AppUser.info?.findMatchingAllergy(foodAllergy)?.let { commonAllergy -> // 인식 정보 O
if (commonAllergy.isEmpty()) { // 사용자 - 식품 알러지 교집합 X
"당신의 알러지 성분은 함유되어 있지 않네요. "
} else { // 사용자 - 식품 알러지 교집합 O
"주의하세요. 해당 식품에는 당신이 유의해야 할 ${commonAllergy.joinToString()} 성분이 함유되어 있습니다. "
}
}
} ?: run { null } // 인식 정보 X

val caloricNutrientMsg: String? = food.nutritions?.filterIsInstance<CaloricNutrient>()
?.let { caloricNutris ->
val total = caloricNutris.sumOf { it.kcal }
val percentTexts = caloricNutris.joinToString(", ")
{ "${it.name}${(it.kcal.toDouble() / total.toDouble() * 100).toInt()}%" }
"해당 식품의 에너지 성분 비율은 $percentTexts 입니다. "
}?: run { null }

val nutrientMsg: String? = food.nutritions?.joinToString(", ")
{ nutri -> "${nutri.name}${nutri.percentageOfDailyValue}" }

val analysisFailMsg: String? =
if (food.allergy == null )
"사진에서 알레르기 정보는 인식되지 않았어요. "
else if (food.nutritions == null)
"사진에서 영양성분표는 인식되지 않았어요. "
else null

val textToSpeak = buildString{
append("당신을 위한 맞춤 식품 분석 결과를 알려드릴게요. ")
calorieMsg?.let { append(it) }
allergyMsg?.let { append(it) }
caloricNutrientMsg?.let { append(it) }
commonAllergyMsg?.let { append(it) }
nutrientMsg?.let { append("해당 식품의 상세 영양성분 정보는 당신의 일일 권장량 기준 $it% 입니다. ") }
analysisFailMsg?.let { append(it) }
append("등록하기 버튼을 누르면, 먹은 양을 조절해 이 식품에 대한 섭취 기록을 남길 수 있어요.")
}
speak(textToSpeak)
}

override fun onStart(utteranceId: String?) {
Log.d("TTSManager", "TTS started: $utteranceId")
}

override fun onDone(utteranceId: String?) {
utteranceIdQueue.remove(utteranceId)
if (utteranceIdQueue.isEmpty()) {
isSpeaking = false
ttsStateListener?.onTTSDone() // 상태 변경 - 음성 출력 정지
}
}

override fun onError(utteranceId: String?) {
Log.d("TTSManager", "TTS error: $utteranceId")
}

fun shutdown() {
tts?.shutdown()
tts = null
isSpeaking = false
utteranceIdQueue.clear()
ttsStateListener?.onTTSDone()
}
}

0 comments on commit 9699e8b

Please sign in to comment.