이번 포스팅에서는 계속해서 '어따세워' 프로젝트의 구현 파트를 다뤄볼 것이다.
주차장을 리스트를 검색할 때, 주차장 명을 직접 타이핑하여 검색할 수도 있겠지만,
사용자의 편의를 위해 음성인식 검색까지 구현해보고자 하였다.
(실행 결과는 코드 맨 아래에 있습니다.)
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="패키지명">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
. . .
</manifest>
우선 음성인식 기능을 구현하기 위해서 Manifest에 RECORD_AUDIO 권한을 추가해주어야 한다.
binding.searchBarContainer.voiceButton.setOnClickListener {
PermissionHelper.checkRecordPermission(this@SearchActivity) { // 권한 승인된 상태라면
openVoiceDialog() // 음성인식 다이얼로그 오픈
}
}
@SuppressLint("MissingSuperCall")
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == PermissionHelper.PERMISSIONS_RECORD_REQUEST_CODE && grantResults.size == PermissionHelper.REQUIRED_PERMISSION_RECORD.size) {
var isGranted = true
// 모든 퍼미션을 허용했는지 체크합니다.
for (result in grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
isGranted = false
break
}
}
if (isGranted) { // 사용자가 권한을 허용했다면
// 음성 지원 다이얼로그 보여주기
openVoiceDialog()
} else {
// 사용자가 직접 권한을 거부했다면
if (ActivityCompat.shouldShowRequestPermissionRationale(
this,
PermissionHelper.REQUIRED_PERMISSION_LOCATION[0]
)
) {
showToast(getString(R.string.permission_record_denied))
}
}
}
}
음성인식 버튼을 눌렀을 때, 권한 허용 여부를 확인한다.
권한이 허용되어 있다면 음성인식을 위한 Dialog를 보여주고, 그렇지 않다면 권한 허용을 요청한다.
위 코드를 활용하여 적절히 구현하면 될 것이다.
// SpeechRecognizer 객체
var mRecognizer: SpeechRecognizer? = null
// 음성으로 검색하기 다이얼로그 보여주기
private fun openVoiceDialog() {
recordDialog?.let {
if (it.isShown.not()) {
// 키보드 닫기
(getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(binding.searchBarContainer.searchBarEditText.windowToken, 0)
it.show() // 음성인식 다이얼로그 show()
mRecognizer =
SpeechRecognizer.createSpeechRecognizer(this) // 새로운 SpeechRecognizer를 만드는 팩토리 메서드
mRecognizer?.setRecognitionListener(getRecognitionListener())
mRecognizer?.startListening(
// 여분의 키
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).putExtra(
RecognizerIntent.EXTRA_CALLING_PACKAGE,
packageName
).putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ko-KR") // 한국어 설정
)
}
}
}
private fun dismissVoiceDialog() {
mRecognizer?.stopListening() // 음성인식 종료
mRecognizer?.destroy()
if (recordDialog?.isShown == true) {
recordDialog?.dismiss()
}
}
권한 허용 부분을 구현하였다면 이제 본격적으로 음성인식 기능을 구현할 차례이다.
먼저 SpeechRecognizer를 전역변수로 선언해준다.
여기서 openVoiceDialog() 메서드는 음성인식 다이얼로그를 보여주면서 mRecognizer 변수를 초기화해주는 함수이다.
음성인식을 시작할 때에는 데이터가 포함된 Intent를 전달해주어야 하는데,
'패키지명'과 STT를 진행할 '언어'를 지정하면 된다.
// 음성인식 리스너
private fun getRecognitionListener(): RecognitionListener {
return object : RecognitionListener {
// 말하기 시작할 준비가되면 호출
override fun onReadyForSpeech(params: Bundle?) {}
// 말하기 시작했을 때 호출
override fun onBeginningOfSpeech() {}
// 입력받는 소리의 크기를 알려줌
override fun onRmsChanged(dB: Float) {}
// 말을 시작하고 인식이 된 단어를 buffer에 담음
override fun onBufferReceived(p0: ByteArray?) {}
// 말하기가 끝났을 때
override fun onEndOfSpeech() {}
// 에러 발생
override fun onError(error: Int) {
mRecognizer?.cancel()
mRecognizer?.startListening(
Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).putExtra(
RecognizerIntent.EXTRA_CALLING_PACKAGE,
packageName
).putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ko-KR")
)
}
// 인식 결과가 준비되면 호출
// 말을 하면 ArrayList에 단어를 넣고 textView에 단어를 이어줌
override fun onResults(results: Bundle?) {
val match = results!!.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.get(0)
viewModel.setSearchQuery(match)
recordDialog?.dismiss()
binding.searchProgressBar.visibility = View.VISIBLE // 프로그레스바 보여주기
}
// 부분 인식 결과를 사용할 수 있을 때 호출
override fun onPartialResults(p0: Bundle?) {}
// 향후 이벤트를 추가하기 위해 예약
override fun onEvent(p0: Int, p1: Bundle?) {}
}
}
또한 SpeechRecognizer에는 Listener를 지정해줄 수 있다.
콜백 메서드들은 아래 표를 참고하면 된다.
메서드 (Method) | 호출 시기 |
onReadyForSpeech(params: Bundle?) | 말하기 시작할 준비가되면 호출 |
override fun onBeginningOfSpeech() | 말하기 시작했을 때 호출 |
override fun onRmsChanged(dB: Float) | 입력받는 소리의 크기를 알려줌 |
override fun onBufferReceived(p0: ByteArray?) | 말을 시작하고 인식이 된 단어를 buffer에 담음 |
override fun onEndOfSpeech() | 말하기가 끝났을 때 |
override fun onError(error: Int) | 에러 발생 |
override fun onResults(results: Bundle?) | 인식 결과가 준비되면 호출 |
override fun onPartialResults(p0: Bundle?) | 부분 인식 결과를 사용할 수 있을 때 호출 |
override fun onEvent(p0: Int, p1: Bundle?) | 향후 이벤트를 추가하기 위해 예약 |
이 상황에서는 음성 결과가 준비되면 호출되는 onResult() 콜백에서 결과값을 처리해줄 필요가 있다.
결과는 리스트형태로 반환되는데 맨 첫번째 값을 가져와 viewModel의 query로 지정해준다.
class SearchViewModel(val repository: ParkingLotRepository) : BaseViewModel() {
private val debounceTime = 300L
private val _searchQuery = MutableStateFlow("") // 검색 쿼리 Flow
val searchMode = MutableLiveData<SearchMode>(SearchMode.TEXT)
// 검색 쿼리값 설정
fun setSearchQuery(query: String?) {
if (query != null) {
_searchQuery.value = query
} else {
_searchQuery.value = ""
}
}
// 쿼리 Flow 변화에 따라 서버에게 값을 받아와 갱신 (LiveData)
// 검색 결과 리스트 Live Data
@ExperimentalCoroutinesApi
@FlowPreview
val searchResult = _searchQuery
.debounce(debounceTime)
.flatMapLatest { query ->
// 검색 쿼리가 2글자 이상일 때
if (query.isNullOrBlank().not() && query.length > 1) {
getLotsFlow(query) // 주차장 리스트 플로우를 가져온다.
} else {
flowOf(ArrayList()) // 값이 비어 있다면 빈 리스트를 반환한다.
}
}
.flowOn(Dispatchers.Default)
.catch { e: Throwable ->
e.printStackTrace()
Timber.d("검색 결과","검색 데이터 가져오기 실패")
}
.catch { e: Throwable -> e.printStackTrace() }
.asLiveData()
. . .
}
그럼 이렇게 Coroutine의 StateFlow 변수인 _searchQuery값을 업데이트해주는데,
StateFlow의 값이 변경될 때마다 Debounce Search를 하여 가장 마지막에 말한 음성 내용을 바탕으로
서버로부터 주차장 데이터 리스트를 받아와 searchResult를 LiveData로 업데이트한다.
(해당 프로젝트에서는 debounceTime을 0.3s로 지정하였습니다.)
// 검색 값을 받으면 리사이클러뷰 갱신
viewModel.searchResult.observe(this) { searchResult ->
with(binding){
// 검색 쿼리가 없는 경우
if(searchResult.isEmpty()) {
// 리사이클러뷰 가리고, No Result 화면을 띄운다.
noResultContainer.visibility = View.VISIBLE
searchRecyclerView.visibility = View.GONE
} else { // 검색 쿼리가 있는 경우
// No Result 화면 가리고, 리사이클러뷰를 띄운다.
noResultContainer.visibility = View.GONE
searchRecyclerView.visibility = View.VISIBLE
}
}
rvAdapter?.updateItems(searchResult) // 리사이클러뷰 어댑터에 새로 가져온 주차장 데이터 업데이트
binding.searchProgressBar.visibility = View.GONE // 결과를 다 가져왔으면, 프로그레스바 가리기
}
결과적으로 searchResult를 관찰하던 부분에서 로직이 수행되는데,
검색 결과가 없다면 No Result 뷰를 띄우고, 결과 값이 있다면 리사이클러뷰를 보여주고 Adapter의 데이터를 업데이트한다.
실행결과
단순히 텍스트 검색으로도 구현할 수 있지만 음성인식을 사용하면 사용자가 더 편리하게 앱을 이용할 수 있을 것이다!
내용에 오류가 있거나, 질문이 있으신 분들은 댓글을 남겨주시면 감사하겠습니다! 😊
'Android' 카테고리의 다른 글
[Android] Listview vs RecyclerView (1) | 2023.04.20 |
---|---|
[Android] This version of the Android Support plugin for IntelliJ IDEA (or Android Studio) 오류 해결 (2) | 2022.12.23 |
[Android] ViewModel + Event Wrapper Pattern 단일 이벤트 처리 (0) | 2022.12.12 |
[Android] Firebase Dynamic Link를 활용하여 사용자 초대링크 생성하기 (2) | 2022.02.08 |
[Android] 자바 코틀린 (Pattern, Matcher)정규식을 사용하여 패스워드 조건을 만들어보자 (0) | 2022.02.05 |