FFmpeg Loudnorm 필터를 이용한 볼륨 평준화 기능

도입

지난 글에서 소개한 MusicBot(https://pyxispub.uzuki.live/?p=1648)의 기능 중에, 볼륨 평준화 기능이 있다.

해당 기능은 volumedetect 필터를 사용해서 max_volume를 추출해서 해당 max_volume에 -1를 곱한 수치를 volume=%sdB 로 설정하는 방식으로 구현되어 있다.

VolumeDetect 필터를 사용하면 다음과 같은 정보를 가지고 올 수 있다.

n_samples: 4845242
mean_volume: -25.8 dB
max_volume: -8.6 dB
histogram_9db: 245
histogram_10db: 4214
histogram_11db: 12522

하지만 volume 필터는 음원에 있는 샘플의 볼륨을 지정한 값 만큼 변환하는 작업을 하기 때문에, 음원의 왜곡이 거의 없으나 클리핑(0dBFS를 넘는 오디오 샘플을 재생하려 할 때 의도되로 재생되지 않는 현상) 이 일어남에 따라 실제 사용하기에는 치명적인 문제점이 존재한다.

따라서 FFmpeg의 Loudnorm(https://ffmpeg.org/ffmpeg-filters.html#loudnorm) 필터를 사용하여 이를 보정하기로 했고, Python으로 구현한 결과를 간단히 정리하려 한다.

Loudnorm 필터의 장점과 단점

앞서 volumedetect 필터는 임계점을 넘어 클리핑이 발생한다고 언급했는데, Loudnorm 필터는 이 임계점을 넘지 않게 하는 True Peak Limiter 를 내장하고 있기 때문에 클리핑이 발생할 수 없게 된다.

또한, 인지 음량(perceived loudness, 사람이 소리를 들을 때 주파수에 따라 인지하는 음량에 차이가 있는 것을 설명)을 고려하기 때문에, 단순히 dBFS 를 기준으로 보정을 가할 때 보다 사람의 귀에 들리는 소리의 크기에 맞춰서 정확하게 측정할 수 있다.

다만 인코딩할 때 시간이 걸리고, 부분부분 적절하게 조절하는 것이 아니고 전체적으로 목표 수치에 맞춰서 조절하는 것이므로 이에 수반하는 다양한 현상 (소리 크기가 왕복하거나, 잡음이 크게 들린다던가, 배경 소리가 크게 들린다던가 등)이 나타나게 되는데, 이는 오디오 자체를 변형시키는 것이기 때문에 왜곡은 당연한 것이라고 봐야 한다.

적용 방법

먼저, single pass 인코딩을 구동하여 인풋된 볼륨 정보를 찾는다. 여기서는 파일을 저장할 필요가 없으므로 -f null 를 통하여 아웃풋을 저장하지 않도록 한다.

ffmpeg -i {input_File} -af loudnorm=I=-16:TP=-1.5:LRA=11:print_format=summary -f null /dev/null

이 결과로, 아래와 같은 텍스트를 얻을 수 있다.

[Parsed_loudnorm_0 @ 00a4e800] 
Input Integrated:   -11.8 LUFS
Input True Peak:     +0.5 dBTP
Input LRA:             7.8 LU
Input Threshold:     -21.9 LUFS

Output Integrated:   -16.0 LUFS
Output True Peak:     -1.5 dBTP
Output LRA:           7.0 LU
Output Threshold:   -26.0 LUFS

Normalization Type:   Dynamic
Target Offset:       -0.0 LU

여기에서 주목해야 되는 값은 Input Integrated, Input True Peak, Input LRA, Input Threshold. Target Offset 이다. 각각 평균 볼륨, 최대 임계점, 볼륨 범위, 임계값, gain offset 를 나타낸다.

이 정보를 반영해서 다시 한번 인코딩을 하면 더 정확하게 나오는데, 이 방법이 dual pass이다.

ffmpeg -i {input_FILE} -af loudnorm=I=-16:TP=-1.5:LRA=11:measured_I=-11.8:measured_TP=0.5:measured_LRA=7.8:measured_thresh=-21.9:offset=0:linear=true::print_format=summary...

Input Integrated 값을 measured_I에, Input True Peak를 measured_TP, Input LRA를 measured_LRA, Input Threshold를 measured_thresh, Target Offset를 offset에 대응시켜서 명령어를 구동하면 된다.

실행한 결과는 다음과 같다.

Input Integrated:    -11.8 LUFS
Input True Peak:     +0.4 dBTP
Input LRA:             7.9 LU
Input Threshold:     -21.8 LUFS

Output Integrated:   -16.0 LUFS
Output True Peak:     -3.8 dBTP
Output LRA:           7.9 LU
Output Threshold:   -26.0 LUFS

Normalization Type:   Linear
Target Offset:       -0.0 LU

음원에 따라 다르겠지만, 사용한 음원의 경우 single pass와 double pass의 목표값(Output Integrated) 는 같게 나왔다. 의도와는 살짝 다르지만 loudnorm 필터는 완벽하게 인코딩을 하는 것이 아닌, 최대한 의도에 맞게 인코딩한 결과를 보장한다고 볼 수 있다.

음원 비교

사용한 음원은 Unminus 의 Lit(https://soundcloud.com/wowamusik/lit-120-bpm-latino-party-wwwwowame?in=wowamusik/sets/free-do-whatever-you-want) 음원이고, 보정한 결과는 다음과 같다.

마무리

loudnorm 필터를 실제 음악 봇에 적용하고 올리고 테스트를 몇 번 거친 결과, 시간은 다소 오래 걸릴지 몰라도 계속 volume를 부분적으로 적용하던 불편함은 없어졌다고 한다.

마지막으로, 구현에 사용한 코드는 다음과 같다.

async def get_loudnorm_argument(self, input_file):
    log.debug('Calculating loudnorm argument of {0}'.format(input_file))
    cmd = '"' + self.get(
        'ffmpeg') + '" -i "' + input_file + '" -af "loudnorm=I=-16:TP=-1.5:LRA=11:print_format=summary" -f null /dev/null'
    output = await self.run_command(cmd)
    output = output.decode("utf-8")

    input_integrated_matches = re.findall("Input Integrated:[ ]*([\-+\d\.]+) LUFS", output)
    input_tree_peak_matches = re.findall("Input True Peak:[ ]*([\-+\d\.]+) dBTP", output)
    input_lra_matches = re.findall("Input LRA:[ ]*([\-+\d\.]+) LU", output)
    input_threshold_matches = re.findall("Input Threshold:[ ]*([\-+\d\.]+) LUFS", output)
    offset_matches = re.findall("Target Offset:[ ]*([\-+\d\.]+) LU", output)

    log.debug(output)

    if input_integrated_matches:
        integrated = float(input_integrated_matches[0])
    else:
        integrated = float(0)

    if input_tree_peak_matches:
        tp = float(input_tree_peak_matches[0])
    else:
        tp = float(0)

    if input_lra_matches:
        lra = float(input_lra_matches[0])
    else:
        lra = float(0)

    if input_threshold_matches:
        threshold = float(input_threshold_matches[0])
    else:
        threshold = float(0)

    if offset_matches:
        offset = float(offset_matches[0])
    else:
        offset = float(0)

    argument = 'loudnorm=I=-16:TP=-1.5:LRA=11:measured_I={0}:measured_TP={1}:measured_LRA={2}:measured_thresh={3}:offset={4}:linear=true:print_format=json' \
            .format(integrated, tp, lra, threshold, offset)

    operate = integrated != float(0) or tp != float(0) or lra != float(0) \
              or threshold != float(0) or offset != float(0)

    log.debug('Calculate complete! argument = {0}, operate = {1}'.format(argument, operate))
    return argument, operate

해당 음악 봇에서는 discords.py로 음원을 전송할 때 ffmpeg로 바로 실행하므로, 여기서는 loudnorm 필터 안에 들어갈 것만 판단하고 보내주는 역할을 한다.

NaraeAudioRecorder, AudioRecorder for Android

회사에서 프로젝트를 진행하던 도중, 음성 녹음에 대한 기능을 개발할 일이 있었다.

예전(아마 2017년쯤)에 쓰던 lameMp3 wrapper가 있었기에 그대로 활용했었는데, 최신 환경에서는 녹음된 소리가 제대로 나오지 않고 이상하게 나오는 현상이 있었다.

그래서, 이번 주말동안 기존에 AudioRecord를 만졌던 기억을 되살려 음성 녹음 라이브러리를 만들게 되었고, 그것이 바로 WindSekirun/NaraeAudioRecorder 이다.

구조

크게 바라보면 해당 라이브러리는 총 4개의 파트로 구성된다.

  • AudioChunk: AudioRecord에서 bufferSize 만큼 읽어온 만큼의 raw 데이터와 raw 데이터에 대한 정보를 담고 있다.
  • AudioRecorder: Wav, PCM, FFmpeg 등 여러 포맷에 대한 객체로 start, stop, resume, pause 이벤트에 맞춰 작업을 실행한다.
  • AudioSource: 기본과 NoiseSuppressor (배경 노이즈를 제거하는 안드로이드 API 전처리기) 에 대한 기본 속성이며, AudioRecord에 대한 정보를 갖고 있다.
  • RecordWriter: AudioSource 와 AudioRecorder 사이에 있는 중간 매개체로 AudioSource로 설정한 AudioRecord에서 bufferSize만큼 raw data를 가져와 OutputStream에 쓰고, 그 이벤트를 AudioRecorder 에 전달하는 역할을 한다.

각각 유기적으로 연결되어있어, 처리 순서는 AudioRecorder > RecordWriter > AudioChunk 이고, AudioSource는 RecordWriter를 사용하기 위한 AudioRecord 설정 객체라 이해하면 편하다.

특이점

특이점이라 한다면 AudioRecord에서 가져오는 데이터는 raw 데이터이고, 이를 wav나 mp3 등으로 변환하기 위해서는 특별한 작업이 필요하다.

예전에도 AudioRecord to Wav 란 주제로 다룬 적이 있었고, 이번에도 특별한 작업은 없다.

mp3, aac, wma 로 변환하기 위해서 FFmpeg를 사용했는데, FFmpeg에 대한 command를 구성하도록 했다

val tempFile = File(file.parent, "tmp-${file.name}")
commandBuilder.addAll(listOf("-y", "-i", file.path))

if (convertConfig.samplingRate != FFmpegSamplingRate.ORIGINAL) {
     commandBuilder.addAll(listOf("-ar", convertConfig.samplingRate.samplingRate.toString()))
}

if (convertConfig.bitRate !== FFmpegBitRate.def) {
    commandBuilder.addAll(listOf("-sample_fmt", convertConfig.bitRate.bitRate))
}

if (convertConfig.mono) {
    commandBuilder.addAll(listOf("-ac", "1"))
}

commandBuilder.add(tempFile.path)

FFmpeg를 다뤄보는 것은 두번째이기 때문에, 다소 그렇게 삽질하지는 않은 것 같다. (아마도… (._.

물론 FFmpeg를 사용한다면 라이센스 문제가 나오기 때문에, 멀티 모듈로 구성해서 core만을 사용한다면 FFmpeg에 의존성이 없게 구성했다. 아무래도 FFmpeg를 프로덕션에서 바로 사용하기에는 부적절한 면도 있기도 하다.

마지막으로 조금 특이한 점이라면, 목적 파일의 확장자에 따라 wav, mp3 등 각각의 recorder로 넘기는데 이 과정에서 리플렉션을 통해 접근했다는 점이다.

class FFmpegRecordFinder : RecordFinder {

    /**
     * see [RecordFinder.find]
     */
    override fun find(extension: String, file: File, writer: RecordWriter): AudioRecorder {
        return when (extension) {
            "wav" -> WavAudioRecorder(file, writer)
            "pcm" -> PcmAudioRecorder(file, writer)
            "aac" -> FFmpegAudioRecorder(file, writer)
            "mp3" -> FFmpegAudioRecorder(file, writer)
            "m4a" -> FFmpegAudioRecorder(file, writer)
            "wma" -> FFmpegAudioRecorder(file, writer)
            "flac" -> FFmpegAudioRecorder(file, writer)
            else -> PcmAudioRecorder(file, writer)
        }
    }

}

RecordFinder 란 클래스에서 find 메서드를 통해 Recorder 로 넘기고, 이 RecordFinder를 Class 객체를 사용하여 바로 인스턴스를 만들고 활용하는 방법으로 접근했다.

 try {
            val finder = recordFinder.getConstructor().newInstance() as? RecordFinder
                    ?: throw IllegalArgumentException(LogConstants.EXCEPTION_FINDER_NOT_HAVE_EMPTY_CONSTRUCTOR)

            val file = recorderConfig.destFile ?: return // it can't be null
            audioRecorder = finder.find(file.extension, file, recordWriter)
} catch (exception: Exception) {
            throw IllegalArgumentException(LogConstants.EXCEPTION_FINDER_NOT_HAVE_EMPTY_CONSTRUCTOR, exception)
}

물론 해당 RecordFinder 클래스를 정확한 형식에 맞추지 않고 개발을 진행했을 경우 사용하지 못한다는 문제점이 있지만, 가능한 멀티모듈로 구성하면서도 유기적으로 연계되기 위해서 해당 방법을 사용했다. (적어도 개발자 자신이 확정할 수 있으면 괜찮지 않을까.. 라는 조금 어린 생각을 가지기도 했다.)

마무리

총 3일동안 개발하면서 나름대로 코드를 최대한 깔끔하게 짜고, 문서화도 제대로 하려고는 노력은 했지만 아직까지 표현력이 부족한 점도 많긴 많았다.

그래도 2019년 한 해의 시작을 라이브러리로 시작했으니, 이번 년도 말에도 라이브러리 개발로 끝날 것 같기도 하다.

마지막으로 개발했던 모든 소스와 샘플들은
WindSekirun/NaraeAudioRecorder 에 공개되어 있다. JCenter에도 배포되어있으니, 바로 다운 받아 사용이 가능하다.