JNI에서 RegisterNatives 사용하기

아득히 먼 예전(약 5년 이상)에는 JNI를 사용하기 위해 javah 를 사용하여 헤더 파일을 만들고, 헤더 파일에 선언된 메서드를 사용하는 일이 있었다.

JNIEXPORT void JNICALL Java_com_github_windsekirun_**_bridge_JniBridge_nativeOnStart(JNIEnv *env, jclass type) {
      LAppDelegate::GetInstance()->OnStart();
}

가령 JniBridge 라는 클래스에 nativeOnStart 라는 메서드가 있다면, 위에 선언된 메서드를 실행하는 방식이다.

하나 두개 쯤은 문제가 없지만, 메서드가 많이 있을때는 아무래도 깔끔하지 못하는 단점이 있었다.

이번에 Live2D를 사용한 토이 프로젝트를 진행하면서 다시 JNI를 사용하게 되었는데, 몇주 전 동적으로 메서드를 등록할 수 있다는 방법을 알게 된 터라 한번 사용해보았다.

RegisterNatives

JNI_OnLoad() (네이티브 라이브러리가 로드될 때 초기화 작업하는 메서드) 에서 사용하며, 아래의 파라미터를 받는다.

jclass clazz

위의 JNIBridge처럼 native 메서드가 선언된 클래스이다.

com/github/windsekirun/**/bridge/JniBridge 처럼 선언된다.

JNINativeMethod*

jni.h에 선언된 구조체로, 아래와 같은 형식이다.

typedef struct {
  const char* name;
  const char* signature;
  void*       fnPtr;
} JNINativeMethod;

name는 native 메서드가 선언된 클래스에서의 메서드 이름, signature는 해당 메서드의 JNI 시그니쳐, fnPtr는 jni에서의 메서드로 보면 된다.

가령 아래와 같은 메서드가 있다고 가정한다.

// java
public static native void nativeOnStart();

// c++
static void nativeOnStart(JNIEnv *env, jclass type) {
  LAppDelegate::GetInstance()->OnStart();
}

이 때, JNINativeMethod는 {"nativeOnStart", "()V", (void *) nativeOnStart} 가 된다.

따라서, 두 번째 파라미터에는 이러한 JNINativeMethod의 배열을 삽입하면 된다.

numMethods

두번째의 배열에 대하여 전체 갯수를 적는다.

실제 사용하기

JNIBridge 라는 클래스에 아래 메서드들이 있다.

public static native void nativeOnStart();
public static native void nativeOnPause();
public static native void nativeOnStop();
public static native void nativeOnDestroy();
public static native void nativeOnSurfaceCreated();
public static native void nativeOnSurfaceChanged(int width, int height);
public static native void nativeOnDrawFrame();
public static native void nativeOnTouchesBegan(float pointX, float pointY);
public static native void nativeOnTouchesEnded(float pointX, float pointY);
public static native void nativeOnTouchesMoved(float pointX, float pointY);
public static native void nativeLoadModel(String modelName);

그리고, 이를 등록할 JNINativeMethod* 를 선언한다.

static const char *classPathName = "com/github/windsekirun/**/bridge/JniBridge";

static JNINativeMethod methods[] = {
      {"nativeOnStart",         "()V",                 (void *) nativeOnStart},
      {"nativeOnPause",         "()V",                 (void *) nativeOnPause},
      {"nativeOnStop",           "()V",                 (void *) nativeOnStop},
      {"nativeOnDestroy",       "()V",                 (void *) nativeOnDestroy},
      {"nativeOnSurfaceCreated", "()V",                 (void *) nativeOnSurfaceCreated},
      {"nativeOnSurfaceChanged", "(II)V",               (void *) nativeOnSurfaceChanged},
      {"nativeOnDrawFrame",     "()V",                 (void *) nativeOnDrawFrame},
      {"nativeOnTouchesBegan",   "(FF)V",               (void *) nativeOnTouchesBegan},
      {"nativeOnTouchesEnded",   "(FF)V",               (void *) nativeOnTouchesEnded},
      {"nativeOnTouchesMoved",   "(FF)V",               (void *) nativeOnTouchesMoved},
      {"nativeLoadModel",       "(Ljava/lang/String)V", (void *) nativeLoadModel},
};

마지막으로 RegisterNative 메서드를 실행하는 메서드를 작성한다.

static int registerNativeMethods(JNIEnv *env, const char *className, JNINativeMethod *gMethods,
                                int numMethods) {
  jclass clazz;
  clazz = env->FindClass(className);
  if (clazz == nullptr) {
      return JNI_FALSE;
  }

  if (env->RegisterNatives(clazz, gMethods, numMethods) < 0) {
      return JNI_FALSE;
  }

  return JNI_TRUE;
}

마지막으로 JNI_OnLoad에서 메서드를 실행한다.

registerNativeMethods(env, classPathName, methods, sizeof(methods) / sizeof(methods[0]));

Sonarqube 분석 시도시 Unable to highlight file 문제 해결

프로젝트를 Sonarqube에 연동하여 분석하려고 할 때 다음과 같은 에러가 나오는 일이 종종 있다.

gradlew sonarqube --stacktrace

* Exception is:
org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':app:sonarqube'.
at org.gradle.internal.concurrent.ThreadFactoryImpl$ManagedThreadRunnable.run(ThreadFactoryImpl.java:55)
Caused by: java.lang.IllegalArgumentException: Unable to highlight file src/main/java/com/github/windsekirun/**/data/ImageArticle.kt
... 32 more
Caused by: java.lang.IllegalArgumentException: Start pointer [line=20, lineOffset=0] should be before end pointer [line=20, lineOffset=0]
... 111 more

해당 문제는 특정 파일(ImageArticle.kt) 의 특정 위치에 해독할 수 없는 문자열이 있는 것으로 파악되는데, 주로 \r\n 와 같은 File Encoding 관련 문제일 가능성이 크다.

이 때에는 IDEA 하단 UTF-8 부분을 클릭하여 UTF-16으로 강제 변환한 다음, 다시 UTF-8로 변경하면 해결되는 경우가 많다.

org.gradle.api.tasks.TaskExecutionException: Execution failed for task ':app:sonarqube'.
Caused by: java.lang.IllegalArgumentException: Unable to highlight file src/main/java/com/github/windsekirun/**/data/VideoArticle.kt
... 32 more
Caused by: java.lang.IllegalArgumentException: 713 is not a valid offset for file src/main/java/com/github/windsekirun/**/data/VideoArticle.kt. Max offset is 655
... 104 more

위와 같은 로그가 나올 때도 있는데, 이 때에는 해당 파일의 인코딩이 UTF-16으로 되어있을 때로, 마찬가지로 UTF-8로 다시 변환해주면 된다.

이렇게 수정한 파일들은 실제로 코드가 변경되지 않았지만, line sperators가 변경된 것으로 나오기도 한다.

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 필터 안에 들어갈 것만 판단하고 보내주는 역할을 한다.