[캡스톤디자인프로젝트] voco 개발 회고

민정·2023년 5월 16일
0

프로젝트 정리

목록 보기
3/3
post-thumbnail

안녕하세요? 지난 1년간 진행한 캡스톤 디자인 프로젝트를 회고해보려 합니다.
1년간 기획하고 개발한 프로젝트가 벌써 마무리됐다니 감회가 새롭습니다.

VOCO 기획 의도

혹시 네이버에서 출시한 클로바 더빙에 대해 들어보신 적 있으신가요?
클로바 더빙은 다양한 AI 보이스를 선택하여 더빙을 생성할 수 있는 더빙 서비스인데요, 최근 클로바 더빙의 성장으로 AI 더빙 시장이 급격하게 성장하고 있습니다. 특히 클로바 더빙은 출시 2년 만에 누적 가입자 수 120만명, 더빙 생성 수 3,450만건을 기록하며 크게 성장 중이라고 하네요.

이러한 클로바 더빙에서 최근 시도 중인 서비스는 사용자 본인의 목소리로 생성되는 더빙 서비스입니다. 사용자의 목소리, 말투, 억양을 학습하여 사용자 개별 TTS를 만드는 것입니다. 저희는 최근에 핫해진 AI 더빙 서비스 + 사용자 본인의 목소리 라는 키워드에 외국어 더빙 서비스라는 키워드를 더하여 voco를 기획하게 되었습니다.

voco의 차별점

voco는 원하는 발음으로, 내가 말하는 듯한 외국어 더빙을 생성합니다. 특이점은 사용자가 원하는 외국인 화자의 말투, 억양을 선택하면 선택한 화자의 말투, 억양에 사용자 본인의 목소리가 더해진 외국어 더빙이 생성된다는 점입니다. 따라서 사용자는 언어가 유창하지 않아도 더빙을 생성할 수 있습니다. 이러한 외국어 더빙은 외국인 화자의 TTS를 이용하여 텍스트를 음성으로 변환한 다음, 해당 음성을 사용자 목소리로 변환하여 구현합니다. 따라서 사용자 목소리로 변환하는 사용자 개별 VC (Voice Conversion) 모델을 만드는 것이 서비스의 핵심이었습니다.
예시1: 제 목소리로 변환한 외국어 더빙 예시입니다
예시2: Voco 더빙을 입힌 영상 예시입니다

VC 모델 선택에 공을 많이 들였는데요, 최종적으로 VC 모델은 starganv2-vc을 선택했습니다. 선택 기준은 non-parallel 모델이어야 한다는 점, 영어 문장 녹음 데이터만으로 모든 언어에 대한 VC가 가능해야 한다는 점, 적은 음성 데이터만으로 학습이 가능해야 한다는 점, 마지막으로 결과물이 깨끗하고 명확해야 한다는 점이었습니다. 모든 부분에서 만족스러운 모델이어서 선택하게 되었습니다. 해당 모델을 사용하므로, 사용자는 약 3분의 녹음만으로 좋은 품질의 더빙을 얻을 수 있습니다.

개발 회고

안드로이드는 코틀린과 안드로이드 스튜디오를 이용하여 개발했습니다. 프로젝트 특성상 오디오 파일을 서버와 주고 받는 요청이 많았기 때문에, 파일을 주고받는 과정이 난이도가 있었던 것 같습니다. 프로젝트를 돌아보며 하나씩 회고해보겠습니다.

사용자 목소리 등록 화면

사용자 개별 VC 모델을 만들기 위해 사용자 목소리를 수집하는 화면입니다. 사용자는 총 80개의 문장을 녹음하게 되는데요, 서버에서 모델 훈련에 사용할 문장 80개를 전달받으면 Recycler View의 adapter에 문장 List를 넘겨 문장을 담은 List Item을 화면에 뿌립니다.

사용자 목소리 녹음에는 AudioRecord, 녹음된 목소리 재생에는 AudioTrack를 사용했습니다. 녹음과 재생과 관련된 함수는 MediaService Object에 담아 관리했습니다. 중요한 포인트는 아래 정도로 요약할 수 있을 것 같습니다.

  • AudioTrack 객체 player와 AudioRecord 객체 recorder는 nullable로 생성하여 사용하지 않을 때는 메모리를 해제해야 합니다.
  • AudioSource를 VOICE_RECOGNITION으로 생성하면 노이즈 제거와 에코 제거가 지원되어 깨끗한 녹음 결과를 얻을 수 있습니다.
  • AudioRecord는 음성을 PCM으로 저장합니다. 저희는 모델 트레이닝을 위해 WAV 파일이 필요했기 때문에, PCM을 서버로 보낸 후 파이썬 라이브러리를 이용하여 WAV 파일로 변환하였습니다. PCM은 원시 데이터이기 때문에 WAV, MP3 등 다양한 형식으로 변환이 쉽다는 장점이 있어 MediaRecorder가 아닌 AudioRecord를 선택하였습니다. PCM은 바이트 형태이기 때문에 녹음 결과를 바이트 단위로 저장(readRecording 함수)하고 재생(readPlaying 함수)해주어야 합니다.
  • 저는 오디오가 재생되는 동안 lineBarVisualization 라이브러리를 이용하여 오디오를 시각화해주었습니다.
  • AudioRecord와 AudioTrack 코드는 아래를 참고해주세요. (MediaService 코드 중 관련된 부분만 모아놨습니다.)
object MediaService{
    var player: AudioTrack? = null // 사용하지 않을 때는 메모리 해제
    private var recorder: AudioRecord? = null // 사용하지 않을 때는 메모리 해제
    private var lineBarVisualizer : LineBarVisualizer? = null
    private var isRecording : Boolean? = null
    private var isPlaying : Boolean? = null
    
    private const val audioSource = MediaRecorder.AudioSource.VOICE_RECOGNITION // for Active noise cancellation
    private const val sampleRate = 44100
    private const val channelCount = AudioFormat.CHANNEL_IN_STEREO
    private const val bitRate = 16
    private const val audioFormat = AudioFormat.ENCODING_PCM_16BIT
    private val bufferSize = AudioTrack.getMinBufferSize(sampleRate, channelCount, audioFormat)

    fun startRecording(filePath: String, recordBinding : ActivityRecordBinding){
        recordBinding.recordWarning.visibility = View.GONE
        recorder = AudioRecord(audioSource, sampleRate, channelCount, audioFormat, bufferSize)
        recorder!!.startRecording()
        isRecording = true
        readRecording(filePath)
    }
    fun stopRecording() {
        isRecording = false
        recorder?.run {
            release()
        }
        recorder = null
    }
    fun startPlaying(page: View, filePath: String) {
        if(File(filePath).exists()){
            isPlaying = true
            player = AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelCount, audioFormat, bufferSize, AudioTrack.MODE_STREAM)
            
            lineBarVisualizer?.release()
            lineBarVisualization(page)
            readPlaying(filePath)
        }
    }
    private fun stopPlaying() {
        player?.stop()
        player?.release()
        player = null
    }
    @SuppressLint("ResourceAsColor")
    private fun lineBarVisualization(view: View) {
        lineBarVisualizer = view.findViewById<LineBarVisualizer>(R.id.visualizer)
        lineBarVisualizer!!.setColor(R.color.pure_white) // setting the custom color to the line.
        lineBarVisualizer!!.setDensity(60F)  // define the custom number of bars we want in the visualizer between (10 - 256).
        lineBarVisualizer!!.setPlayer(player!!.audioSessionId)  // Setting the player to the visualizer.
    }
    
    private fun readRecording(filePath: String) = CoroutineScope(Dispatchers.Default).launch {
        val readData = ByteArray(bufferSize)
        var fos: FileOutputStream? = null
        try {
            withContext(Dispatchers.IO) {
                fos = FileOutputStream (filePath)
            }

            while (isRecording!!) {
                val ret: Int = recorder!!.read(readData, 0, bufferSize) //  AudioRecord의 read 함수를 통해 pcm data 를 읽어옴

                withContext(Dispatchers.IO) {
                    fos?.write(readData, 0, bufferSize)
                } //  읽어온 readData 를 파일에 write
            }
            recorder?.release()
            recorder = null
            withContext(Dispatchers.IO) {
                fos?.close()
            }
        }catch (e: Exception) {
            e.printStackTrace()
        }
    }
    private fun readPlaying(filePath: String) = CoroutineScope(Dispatchers.Default).launch {
        try{
            val writeData = ByteArray(bufferSize)
            var fis: FileInputStream? = null
            withContext(Dispatchers.IO){
                fis = FileInputStream(filePath)
            }
            val dis = DataInputStream(fis)
            player?.play()

            while(isPlaying!!){
                val ret = dis.read(writeData, 0, bufferSize)
                if(ret <= 0){
                    isPlaying = false
                    break
                }
                player?.write(writeData, 0, ret)
            }

            stopPlaying()

            withContext(Dispatchers.IO){
                dis.close()
                fis?.close()
            }
        }catch(e: Exception){

        }
    }
}

레이아웃 구현 과정을 궁금해하시는 분도 계실 것 같은데요, 레이아웃은 viewPager2를 응용하여 구현하였습니다. viewPager2는 RecyclerView를 기반으로 만들어졌다는 사실 알고 계셨나요? 그래서 작동 방식도 RecyclerView와 굉장히 유사합니다.

따라서 화면과 같은 레이아웃은 viewPager2 컴포넌트의 setPageTransformer를 재정의하면 됩니다. setPageTransformer에서는 리스트뷰와 리스트뷰의 상대 위치를 람다형으로 사용할 수 있어 사이드뷰를 띄우고, 사이드뷰의 레이아웃을 조정할 수 있습니다.

  • 리스트뷰에 접근하여 화면처럼 사이드뷰의 스케일 조정, 알파값 조정이 가능합니다.
  • 화면처럼 사이드뷰를 띄우는 방법은 중앙뷰와 사이드뷰 사이의 Offset Pixel을 구한 다음에, 사이드뷰의 translationY를 position * -offsetPx로 설정해주면 됩니다.

팀 스페이스 생성 및 참여

사용자는 같은 팀 스페이스에 소속된 다른 팀원의 목소리를 이용하여 더빙을 생성할 수 있습니다. 본인을 포함한 여러명의 목소리로 더빙을 생성해야 하거나, 여러명과 더빙 텍스트를 작업해야할 때 유용한 기능입니다.

팀 스페이스 참여는 초대 코드를 통해 이뤄지고, 워크 스페이스는 상단의 팀을 클릭하여
전환할 수 있습니다. 팀과 프로젝트 목록은 RecyclerView를 이용해 구현하였고, 프로젝트는 FragmentStatePagerAdapter를 이용하여 2개의 Tab으로 나눠주었습니다.

팀 생성 및 참여에 필요한 정보는 BottomSheet을 이용하여 받았습니다. BottomSheet을 상속받으면 화면과 같은 애니메이션을 쉽게 구현할 수 있습니다.

프로젝트 생성 및 상세보기

사용자는 블럭 단위로 텍스트를 입력합니다. 블럭의 EditText의 focus가 아웃되면 코루틴을 이용하여 블럭 텍스트에 대한 더빙을 비동기로 생성합니다. 따라서 블럭의 더빙이 생성되는 동안에도 다른 블럭의 텍스트 편집 및 수정이 가능합니다.

맨 아래 컨트롤러의 재생 버튼을 누르면 모든 블럭의 더빙이 합쳐진 전체 더빙을 들을 수 있습니다. 블럭 단위의 더빙과 전체 더빙은 모두 AWS S3에 저장되어 있기 때문에 ExoPlayer를 이용하여 더빙을 실시간으로 스트리밍해주었습니다.

ExoPlayer는 유튜브 등 흔히 알려진 스트리밍 서비스에서 실제로 사용되는 플레이어입니다. 관련 함수는 마찬가지로 MediaService 객체에 담아 관리해주었습니다.
startExoPlayerUrl 함수에 S3 Url을 넘겨주면 오디오를 스트리밍할 수 있습니다.

fun setExoPlayerUrl(context: Context, playerView: PlayerControlView?,mediaUrl: String){
        exoPlayer?.stop()
        exoPlayer?.release()

        val trackSelector = DefaultTrackSelector(context)
        // Global settings.
        exoPlayer = SimpleExoPlayer.Builder(context)
            .setTrackSelector(trackSelector)
            .build()

        playerView?.player = exoPlayer
        // 미디어 데이터가 로드되는 DataSource.Factory 인스턴스 생성
        dataSourceFactory = DefaultDataSourceFactory(context, userAgent)

        // Media를 플레이 할 미디어 소스를 생성.
        val mediaSourceFactory = ProgressiveMediaSource.Factory(dataSourceFactory)
        val mediaSource = mediaSourceFactory.createMediaSource(Uri.parse(mediaUrl))

        // MediaSource로 플레이 할 미디어를 player에 넣어줌
        exoPlayer?.run{
            prepare(mediaSource, false, false)
            addListener(object : Player.EventListener {
                override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) {
                    if(isPlaying){
                        // change to play button
                        BlockAdapter.player?.stop()
                    }
                }
            })
        }
    }
    fun releaseExoPlayer(){
        exoPlayer?.release()
        exoPlayer = null
    }
    @RequiresApi(Build.VERSION_CODES.Q)
    fun downloadAudio(view: View, IDENTITY_POOL_ID: String, REGION: Regions, BUCKET: String, project: Project, block: Block?, fileKey: String){
        // create CredentialsProvider object
        val credentialsProvider = CognitoCachingCredentialsProvider(
            view.context.applicationContext,
            IDENTITY_POOL_ID, // 자격 증명 풀 ID
            REGION, // region
        )

        TransferNetworkLossHandler.getInstance(view.context.applicationContext)

        // create TransferUtility object
        val transferUtility = TransferUtility.builder()
            .context(view.context.applicationContext)
            .defaultBucket(BUCKET) // bucket name
            .s3Client(AmazonS3Client(credentialsProvider, Region.getRegion(REGION)))
            .build()

        val title = when(block){
            null -> "${project.title.replace(" ","_")}_${Language.values()[project.language].name.lowercase()}_${project.id}.wav" // project dubbing download
            else -> "${project.title.replace(" ","_")}_${Language.values()[project.language].name.lowercase()}_block${block.id}.wav" // block dubbing download
        }
        val filePath =  File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath, title)
        val downloadObserver = transferUtility.download(fileKey, filePath) // start download
        // download progress listener
        downloadObserver.setTransferListener(object : TransferListener {
            override fun onStateChanged(id: Int, state: TransferState) {
                if (state == TransferState.COMPLETED) {
                    if(block != null) view.alpha = 1F
                    Toast.makeText(view.context, "다운로드가 완료되었습니다", Toast.LENGTH_SHORT).show()
                }
            }

            override fun onProgressChanged(id: Int, current: Long, total: Long) {
                try {
                    val done = (((current.toDouble() / total) * 100.0).toInt()) //as Int

                }
                catch (e: Exception) {
                    Log.d("AWS", "Trouble calculating progress percent", e)
                }
            }

            override fun onError(id: Int, ex: Exception) {
                Toast.makeText(view.context, "다운로드에 실패했습니다", Toast.LENGTH_SHORT).show()
            }
        })
    }

데이터 모델

데이터는 RoomDB와 Shared Preference를 이용하여 저장해주었습니다. RoomDB에는 Country, Project, Block, Team, Voice 테이블을 저장하였고, Shared Preference에는 사용자 로그인 관련 정보와 accessToken 등을 저장하여 관리하였습니다.

@Entity(tableName="Country")
data class Country(
    @PrimaryKey @ColumnInfo(name="countryId") val countryId: Int,
    @NotNull @ColumnInfo(name = "countryName") val countryName: String,
)
@Entity(tableName="Project", primaryKeys = ["id"])
data class Project(
    @ColumnInfo(name="id") val id: Int,
    @ColumnInfo(name="team") val team: Int,
    @ColumnInfo(name = "title") var title: String,
    @ColumnInfo(name = "language") val language: Int,
    @ColumnInfo(name = "updatedAt") val updatedAt: String,
    @ColumnInfo(name = "bookmarked") var bookmarked : Boolean,
)
@Entity(tableName="Block", primaryKeys = ["id"])
data class Block(
    @ColumnInfo(name="id") val id: Int,
    @ColumnInfo(name = "text") var text: String,
    @ColumnInfo(name="audioPath") var audioPath: String,
    @ColumnInfo(name="interval") var interval: Int,
    @ColumnInfo(name="voiceId") var voiceId: Int,
    @ColumnInfo(name="order") var order: Int
)
@Entity(tableName="Team", primaryKeys = ["id"])
data class Team(
    @ColumnInfo(name="id") val id: Int,
    @ColumnInfo(name="name") val name: String,
    @Nullable @ColumnInfo(name="teamCode") val teamCode: String,
    @ColumnInfo(name = "private") var private: Boolean,
)
@Entity(tableName="Voice", primaryKeys = ["id"])
data class Voice(
    @ColumnInfo(name="id") val id: Int,
    @ColumnInfo(name="nickname") val name: String,
)

Request 및 Response 처리

예전 프로젝트에서는 Call enqueue를 이용해서 응답을 비동기로 처리해주었는데, 이번에는 코루틴과 Response를 이용해서 응답을 처리해보았습니다. 어떤 방법이 더 좋은지는 아직 모르겠지만, 비동기 처리를 자세히 공부해보고 싶어서 코루틴 방식을 택해보았습니다.
예시로 프로젝트의 제목을 수정하는 요청 코드를 하나 가져왔습니다.

private var apiService = RetrofitClient.getRetrofitClient(true).create(Api::class.java)
...
fun updateProjectTitle(teamId: Int, projectId: Int, title: String) = CoroutineScope(Default).launch{
        try{
            val request = CoroutineScope(IO).async{ apiService.updateProjectTitle(teamId, projectId, hashMapOf(Pair("title",title)))}
            val response = request.await()
            when(response.code()){
                200->{
                    val localDao = AppDatabase.getProjectInstance(context)!!.ProjectDao()
                    localDao.updateTitle(projectId, title)
                }
                else -> showToast(R.string.toast_request_error)
            }
        }catch (e:Exception){
            showToast(R.string.toast_network_error)
        }
    }

제가 분리한 코드를 잠깐 설명하자면, Api::class.java는 Api를 모아놓은 인터페이스이고, RetrofitClient는 Retrofit2와 관련된 함수를 모아놓은 객체입니다. showToast는 Toast를 띄우는 과정을 추상화한 함수입니다.

다시 요청 처리 함수로 돌아오면, async로 요청을 보낸 후 await로 응답을 비동기로 받았습니다. response는 Response 클래스 타입이기 때문에 response의 code에 따라 콜백 코드를 작성해주었습니다.

주의할 점은 화면 레이아웃과 관련된 코드는 Main에서 일괄적으로 처리해야 합니다.
데이터 바인딩과 관련된 코드를 다른 CoroutineScope에서 처리하게 되면 하나의 자원에 여러 스레드가 접근하는 상황이 발생하여 에러가 발생합니다. 이외의 job들은 Defalt와 IO에 나누면 되는데요, 통상적으로 데이터를 변경하거나 요청을 보내는 코드는 IO에서 처리합니다.

서버와 파일 주고받기

interface Api {
...
    
    // 목소리 등록
    @Multipart
    @POST("/inputs/{textId}")
    suspend fun setVoice(
        @Path("textId") textId: Int,
        @Part audio: MultipartBody.Part
    ) : Response<HashMap<String,Double>>
    
    ...
}

이번 프로젝트에서 처음 해본건 오디오 파일을 서버로 전달하거나, 서버로부터 오디오 파일을 전달받아 안드로이드에 띄우는 Api 처리였습니다. 파일은 MultiPart를 이용해서 주고받을 수 있었는데요,

val file = File(audioPath)
val requestFile = file.asRequestBody("audio/wav".toMediaTypeOrNull())
val body = MultipartBody.Part.createFormData("audio", file.name, requestFile)
val request = CoroutineScope(IO).async { apiService.setVoice(textId, body) }

위의 코드처럼 RequestBody를 생성한 다음, MultipartBody에 담아 넘겨주면 서버와 파일을 주고받을 수 있었습니다. 주의할 점은 MultipartBody에서 createFormData의 첫번째 인수가 바로 데이터의 키라는 점입니다😅

다른 블로그에 첫번째 인수와 두번째 인수가 모두 파일 이름이라고 설명되어 있어 삽질을 오랫동안 했었습니다... 첫번째 인수에는 JSON 파일의 왼쪽에 들어가는 변수 이름을 적어주고, 두번째 인수에는 파일의 실제 이름을 넣어주면 정상적으로 요청이 들어갑니다.

후기

이외에도 Firebase를 이용한 푸시알림, SNS 로그인 등 다양한 기능을 구현한 프로젝트였습니다. 하나의 기능은 정말 다양한 방법으로 구현할 수 있더라구요. 어떤 로직이 맞는 방법인지 계속 고민하며 기능을 완성해갔는데, 검색만으론 정론을 알기 어려워 실무를 경험하고 싶다는 생각도 많이 했습니다. 혼자 안드로이드 개발을 맡다보니 공부할 부분이 정말 많았지만 좋은 코드를 쓰기 위해 제대로 공부하는 과정에서 실력도 성장한 것 같습니다. 1년간 기획하고 개발한 저의 캡스톤 디자인 프로젝트도 드디어 끝이 나네요! AI부터 안드로이드 개발까지 공부한 지식을 모두 쏟아부은 프로젝트인 것 같습니다.

끝으로 1년간 저와 프로젝트를 진행한 똑똑하고 멋진 언니들 덕분에 좋은 프로젝트를 완성할 수 있었습니다....😊 좋은 팀원들과 함께할 수 있어서 행복했습니당 ❤️

그럼 회고를 마치겠습니다!
감사합니다.

0개의 댓글