[DIP/FE] 노래 추천받기(1) - 장소 선택, 데시벨 측정, 목표 설정

2026. 3. 7. 23:51·TAVE-16th

노래 추천받기 개요

홈화면에서 "시작하기" 버튼을 누르면 노래 추천을 위한 프래그먼트로 이동한다. 사용자가 노래를 추천받기 위해서는 총 3단계의 사용자 맞춤 설정 과정을 거쳐야 한다.

 

1 단계. 장소 선택
2 단계. 데시벨 측정
3 단계. 목표 설정

 

각 단계는 현재 사용자의 Context를 수집하기 위한 과정으로, 사용자가 마지막 단계까지 설정을 완료하면 모든 정보를 서버로 전송한다.

 

서버에서는 LangGraph 기반 AI Agent를 사용하여 사용자가 방금 고른 상황과 온보딩 단계에서 설정한 아티스트 취향 등을 종합적으로 분석해 맥락 기반 음악 추천을 수행한다.

추천받은 노래는 사용자의 취향에 맞춰 리스트형과 갤러리형 등 2가지 모드로 볼 수 있는데, 이건 다음 포스팅에서 자세히 설명할 예정이다.



 


 

Ⅰ. 장소 선택하기

1. 장소 선택 로직

노래 추천의 첫 번째 단계는 현재 사용자가 있는 장소를 선택하는 것이다.

앱 구상 처음에는 사용자의 위치 권한을 획득하여 실제 위치를 수집하고자 하였다. 하지만 GPS로 얻은 좌표만으로는 카페인지 지하철인지 정확히 구분할 수 없고, 데이터가 너무 방대하다고 생각하였다. 따라서 최종적으로 사용자가 직접 장소를 선택하는 방식으로 수정하였다.

선택 가능한 장소는 집/실내, 카페, 코워킹, 헬스장, 도서관, 이동중, 공원 등 7가지이다. 사용자가 하나의 장소를 선택하면 "다음으로" 버튼이 활성화 된다. 사용자는 같은 버튼을 눌러 선택을 취소할 수도 있고 다른 버튼을 눌러 변경할 수도 있다. 단, 장소는 반드시 하나만 선택할 수 있다.

 

2. 구현 코드

1) 장소 데이터 관리

장소는 총 7개가 있는데 사용자게에 보여줄 한국어 이름, 서버로 보낼 영문 이름, 아이콘, XML layout id 등을 관리해야 했다. 그래서 각각의 데이터를 하나로 묶기 위해 Place 데이터 클래스를 만들고 List로 관리하였다.

data class Place(
    val id: String,
    val name: String,
    val englishName: String,
    val iconColorId: Int,
    val iconDrawableId: Int,
    val wrapperId: Int
)

private val allPlaceData = listOf(
        Place("p0", "집/실내", "home", R.color.btn_pink, R.drawable.place_icon0, R.id.center_button),
        Place("p1", "카페", "cafe", R.color.btn_orange, R.drawable.place_icon1, R.id.btn1_wrapper),
        Place("p2", "코워킹", "co-working", R.color.btn_blue, R.drawable.place_icon2, R.id.btn2_wrapper),
        Place("p3", "헬스장", "gym", R.color.btn_purple, R.drawable.place_icon3, R.id.btn3_wrapper),
        Place("p4", "도서관", "library", R.color.btn_yellow, R.drawable.place_icon4, R.id.btn4_wrapper),
        Place("p5", "이동중", "moving", R.color.btn_red, R.drawable.place_icon5, R.id.btn5_wrapper),
        Place("p6", "공원", "park", R.color.btn_green, R.drawable.place_icon6, R.id.btn6_wrapper)
    )

 

2) 원형 배치 레이아웃 계산

장소 선택의 버튼 레이아웃이 그냥 정적으로 각각 좌표를 계산해서 배치하면 핸드폰 기종에 따라 버튼이 잘리는 문제점이 발생하였다.

따라서, 중앙 버튼을 중심으로 나머지 버튼들이 원형으로 배치되는 구조로 만들었고, 반지름을 동적으로 계산하여 기기 화면 크키에 상관없이 원형 버튼을 중심으로 나머지 6개의 버튼이 감싸고 있는 구조를 만들 수 있었다.

binding.buttonGroupWrapper.post {
    val minSide = Math.min(binding.buttonGroupWrapper.width, binding.buttonGroupWrapper.height)
    val safeRadius = (minSide * 0.32).toInt()

    val wrappers = listOf(
        binding.btn1Wrapper, binding.btn2Wrapper, binding.btn3Wrapper,
        binding.btn4Wrapper, binding.btn5Wrapper, binding.btn6Wrapper
    )

    wrappers.forEach { wrapper ->
        val params = wrapper.layoutParams as androidx.constraintlayout.widget.ConstraintLayout.LayoutParams
        params.circleRadius = safeRadius
        wrapper.layoutParams = params
    }
}

 

3) 선택 상태 핸들링

버튼을 누르면 이전에 선택한 버튼을 원래대로 되돌리고, 새로 선택된 버튼에는 그라데이션은 입혔다. 선택되지 않은 버튼들의 alpha값을 조절해 뒷배경 색깔을 조절했다.

private fun handlePlaceSelection(newlySelectedWrapper: View) {
    val selectedData = allPlaceData.find { it.wrapperId == newlySelectedWrapper.id } ?: return

    // 1. 토글 로직: 이미 선택된 걸 다시 누르면 해제, 아니면 새로 지정
    if (selectedButtonWrapper == newlySelectedWrapper) {
        selectedButtonWrapper = null
    } else {
        // 2. 그라데이션 및 아이콘 틴트 적용
        val selectedColorInt = ContextCompat.getColor(requireContext(), selectedData.iconColorId)
        val gradientDrawable = GradientDrawable(
            GradientDrawable.Orientation.TL_BR,
            intArrayOf(Color.TRANSPARENT, Color.argb(0x40, ...), selectedColorInt)
        ).apply {
            shape = GradientDrawable.OVAL
            gradientType = GradientDrawable.RADIAL_GRADIENT
            gradientRadius = newlySelectedWrapper.width * 0.7f
        }

        newlySelectedWrapper.background = gradientDrawable
        selectedButtonWrapper = newlySelectedWrapper

        // 뷰모델에 데이터 저장
        viewModel.place = selectedData.name
        viewModel.englishPlace = selectedData.englishName
    }

    // 3. 주변 버튼 Alpha 처리 (뿌옇게 만들기)
    val isSelected = selectedButtonWrapper != null
    allButtons.forEach { wrapper ->
        val targetAlpha = if (!isSelected || wrapper == selectedButtonWrapper) 1.0f else 0.4f
        wrapper.alpha = targetAlpha
        // 텍스트뷰도 같이 투명도 조절
    }
}



 


 

Ⅱ. 데시벨 측정하기

1. 데시벨 측정 로직

사람의 청각은 자극량의 변화를 선형적으로 느끼지 않고 대수적으로 느낀다. 이런 인간의 청각 특성을 반영하기 위해 소리의 크기는 log scale을 사용하는 데시벨 단위로 표현한다.

스마트폰 마이크는 실제 음압을 직접 측정하지 못하기 때문에, 측정된 신호의 RMS 값을 기반으로 상대적인 dB 값을 계산하는 방식으로 구현된다.

사용자가 데시벨 프래그먼트로 넘어오면 2초 뒤 자동적으로 5초 동안 데시벨을 측정하게 된다. 5초 동안의 평균값을 계산하여 해당 환경의 대표 데시벨 값으로 사용했다.

 

2. 핸드폰 마이크 권한 획득하는 방법

매니페스트에 permission을 추가한다.

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

마이크 권한(RECORD_AUDIO)이 없으면 요청하고, 있으면 AudioRecord 객체를 생성한다. MediaRecorder보다 AudioRecord를 쓴 이유는 실시간으로 PCM 데이터를 만져서 직접 데시벨을 계산하기 위해서 이다.

private fun startRecording() {
    if (ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.RECORD_AUDIO)
        != PackageManager.PERMISSION_GRANTED) {
        requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_RECORD_AUDIO_PERMISSION)
        return
    }

    audioRecord = AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLE_RATE, CHANNEL, ENCODING, bufferSize)
    audioRecord?.startRecording()
    isRecording = true

    // 별도 스레드에서 측정 시작 (UI 블로킹 방지)
    recordingThread = Thread { measureDecibel() }
    recordingThread.start()
}

 

3. 구현 코드

1) 상수 설정 및 자동화 예약

companion object {
    private const val SAMPLE_RATE = 44100
    private const val CHANNEL = AudioFormat.CHANNEL_IN_MONO
    private const val ENCODING = AudioFormat.ENCODING_PCM_16BIT
    private const val RECORDING_DURATION = 5000L // 5초 동안 측정
    private const val AUTO_START_DELAY = 2000L   // 2초 뒤 자동 시작
}

// 화면 진입 2초 뒤 자동으로 측정 시작 예약
autoStopHandler.postDelayed(autoStartRunnable, AUTO_START_DELAY)

 

2) 실시간 데시벨 계산 로직

버퍼에서 소리 데이터를 읽어와 제곱평균제곱근(RMS)을 구하고, 로그를 씌워 데시벨로 변환한다. 이때 값이 너무 튀는 것을 막기 위해 Exponential Moving Average 방식을 사용했다.

private fun measureDecibel() {
    val audioBuffer = ShortArray(bufferSize)
    var smoothedDB = 0.0
    val ALPHA = 0.1 // 부드러운 변화를 위한 보정값

    while (isRecording && audioRecord != null) {
        val readSize = audioRecord!!.read(audioBuffer, 0, bufferSize)
        if (readSize > 0) {
            var sumOfSquares = 0.0
            for (i in 0 until readSize) {
                sumOfSquares += audioBuffer[i] * audioBuffer[i]
            }
            val rms = kotlin.math.sqrt(sumOfSquares / readSize)

            // RMS -> dB 변환
            val dB = if (rms > 0) 20 * kotlin.math.log10(rms) else 0.0

            // 필터링 적용 (이전 값과 현재 값의 적절한 조화)
            smoothedDB = ALPHA * dB + (1 - ALPHA) * smoothedDB
            allRecordedValues.add(dB) // 평균을 내기 위해 저장

            activity?.runOnUiThread {
                binding.tvDecibelValue.text = "${String.format("%.1f", smoothedDB)} dB"
            }
        }
    }
}

3) 결과 분석

5초가 지나면 측정을 멈추고 수집된 모든 데시벨 값의 평균치를 낸다.

private fun stopRecording() {
    isRecording = false
    audioRecord?.stop()
    audioRecord?.release()
    audioRecord = null

    if (allRecordedValues.isNotEmpty()) {
        currentDecibelValue = allRecordedValues.average() // 5초간의 평균값
    }

    updateUI(DecibelState.FINISHED)
    viewModel.decibel = currentDecibelValue.toFloat() // 뷰모델에 저장
}

// 데시벨 값에 따른 결과 설명 
val descriptionText = when {
    currentDecibelValue < 55.0 -> "조용한 곳이예요"
    currentDecibelValue < 85.0 -> "약간 시끄러운 곳이예요"
    else -> "매우 시끄러운 곳이예요"
}



 


 

Ⅲ. 목표 선택하기

목표 선택 로직은 장소 선택과 비슷하지만, 여기엔 '미선택(Neutral)'이라는 옵션을 하나 더 추가했다.

 

1) 목표 데이터 정의

data class Goal(
    val id: String,
    val name: String,
    val englishName: String,
    val iconColorId: Int, 
    val iconDrawableId: Int, 
    val wrapperId: Int      
)

private val allGoalData = listOf(
    Goal("g0", "수면", "sleep", R.color.btn_pink, R.drawable.goal_icon0, R.id.center_button),
    Goal("g1", "집중", "focus", R.color.btn_orange, R.drawable.goal_icon1, R.id.btn1_wrapper),
    // ... (생략)
    Goal("g7", "미선택", "neutral", R.color.btn_empty, R.drawable.goal_icon7, R.id.btn7_wrapper)
)

 

2) 최종 데이터 저장 및 화면 이동

binding.nextBtn.setOnClickListener {
    val selectedData = allGoalData.find { it.wrapperId == selectedButtonWrapper?.id }
    if (selectedData != null) {
        viewModel.goal = selectedData.name
        viewModel.englishGoal = selectedData.englishName
        viewModel.checkData() // 데이터 누락 없는지 최종 확인
    }
    // AI 결과 프래그먼트로 이동!
    findNavController().navigate(R.id.action_recGoalFragment_to_recResultFragment)
}



 


 

Ⅳ. 마무리 회고

 

데시벨을 측정한다는 임무가 주어졌을 때 조금 막막하긴 했다. 하지만, 안드로이드 폰에서 마이크 권한을 쉽게 얻어 데시벨을 측정을 할 수 있어 작업을 수월했던 것 같다.

 

장소 선택과 목표 선택도 UI는 비슷하여 개발 속도가 붙었던 것 같다. 하지만 버튼 뒤 그라데이션을 구현하는게 까다로워 다른 팀원이 조금 고생해줬당..!

 

다음 포스팅에는 여기서 얻은 사용자 환경을 서버로 보내는 내용을 포스팅 해보도록 하겠다. Polling 방식을 사용한 것이 처음이라 자세하게 적어보면 좋을 것 같기 때문이다.

 

깃허브 바로가기: DIP android Github 바로가기



'TAVE-16th' 카테고리의 다른 글

[DIP/FE] Playlist 추천 결과 - 리스트형 & 갤러리형 보기(ViewPager2)  (0) 2026.03.08
[DIP/FE] 노래 추천받기(2) - Polling 방식으로 서버 요청 보내기  (0) 2026.03.08
[DIP/FE] 온보딩 구현 - 문자열 검사 및 Spotify API로 아티스트 목록 가져오기  (0) 2026.03.07
[DIP/FE] 프로젝트 소개 & Kakao Oauth 구현  (0) 2026.02.23
[TAVE 스터디 5주차] 메모 앱 만들기  (0) 2026.01.04
'TAVE-16th' 카테고리의 다른 글
  • [DIP/FE] Playlist 추천 결과 - 리스트형 & 갤러리형 보기(ViewPager2)
  • [DIP/FE] 노래 추천받기(2) - Polling 방식으로 서버 요청 보내기
  • [DIP/FE] 온보딩 구현 - 문자열 검사 및 Spotify API로 아티스트 목록 가져오기
  • [DIP/FE] 프로젝트 소개 & Kakao Oauth 구현
choisio2
choisio2
sio2-dev 님의 블로그 입니다.
  • choisio2
    SiO2 for Developer
    choisio2
  • 전체
    오늘
    어제
    • 분류 전체보기 (46) N
      • TAVE-16th (14)
      • BDA-11th (16)
      • C++ (5)
      • 개인 프로젝트 (4)
      • 백준 (4) N
      • 컴퓨터 그래픽스 (1)
      • 잡담 (1)
  • 블로그 메뉴

    • 태그
    • 방명록
  • 링크

    • github.com/choisio2
  • 공지사항

  • 인기 글

  • 태그

    백준
    frontend
    바이브코딩
    BDAI
    데시벨측정
    kotlin
    BDA #데이터분석모델링
    코딩테스트
    KakaoOauth
    개발자미래
    개발자
    프론트엔드
    polling
    알고리즘스터디
    SpotifyAPI
    playconsole
    BDA
    viewpager2
    데이터분석모델링
    androidstudio
    AI시대
    calculator
    코테
    C++
    spotify
    geminicli
    백준1463
    알고리즘
    Tave
    kotin
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
choisio2
[DIP/FE] 노래 추천받기(1) - 장소 선택, 데시벨 측정, 목표 설정
상단으로

티스토리툴바