GPU – What is Look Up Table (LUT) ?

LUT 가 무엇일까? 

LUT 는 기본적으로 Look Up Table 의 약자로, 원본 이미지의 색상, 채도, 밝기 값을 변경하여 새로운 RGB 영상 값을 생성하는 수학적인 정확한 방법이다.

Look Up Table 는 과학적으로 정밀할 수 있으며 (예로 들어, sRGB 컬러 공간에서 DCI P3 컬러 공간으로 이동한다던지), 이미지에 특정한 효과를 적용하기 위해서도 사용할 수 있다.

쉽게 말해서, 이미지에 필터를 걸 수 있게 해주는 기능이다.

이런 이미지가 있는데, 왼쪽 상단의 원본 이미지를 밑과 오른쪽과 같은 사진으로 보정하는 기능을 LUT 로도 해줄 수 있다.

다만, 추가적인 텍스쳐나 블러, 선명도, 그리고 간단한 텍스쳐 접근법으로 작동하지 않는 필터의 경우는 적용할 수 없다.

즉, 간단한 Curve, Layer, Selective Color 만 변경이 가능하다.

즉 인스타그램 같이 복잡한 필터의 경우 다중으로 필터를 적용시킴으로서 적용을 해야하지만, 단순한 색상 보정 급의 필터라면 LUT 로도 충분히 커버가 가능하다.

문젠, 이거를 어떻게 앱에 적용하냐.. 인데, 언젠가 설명할 기회가 올  것 같다.

아직까지는 연구중이라 글로 작성하기엔 미흡한 점이 많을 것 같다.

다만 Android-GPUImage 라는 iOS 측의 GPUImage 라이브러리의 포팅 버전 을 사용, GPUImageLookupFilter 를 어떻게든 적용시키면 되는 것 같다.

Big Endian vs Little Endian

지난 AudioRecord to Wav 글 에서 나왔던 Little Endian.

뭔가 한번이라도 정리해 둬야 될 것 같았다.

Left - Big Endian , Right - Little Endian

왼쪽이 Big-Endian, 오른쪽이 Little-Endian 이다.

그림으로 정리하면 위랑 같은데, 0x16170C0D 라는 값이 있다고 해보자.

Big Endian

메모리에는 16, 17, 0C, 0D 순으로 들어가면 Big Endian 이다. 사람이 숫자를 읽고 쓰는 방법과 동일하기 때문에 비교적 디버깅 하기 쉽다.

Little Endian

메모리에 역순으로 0D, 0C, 17, 16 순으로 들어가면 Little Endian 이다. 언뜻 보면 반대로 표현되기에 디버깅을 하기 어렵다는 점이 있다. 하지만 리틀 엔디안 에는 다른 장점이 있다.

예를 들어서, 32-bit integer 인 0x2A 는 Little Endian 환경에서는 2A 00 00 00 으로 표현되, 앞에 부분만 따내면 쉽게 하위 비트를 얻을 수 있다. 보통 첫 바이트를 주소로 삼는 성질이 있어 프로그래밍을 편하게 한다.

지난 글에서 설명한 WAV 헤더를 다시 한번 봐보자.

wav header

이 중 FMT 영역의 Chunk Size 에 대해 설명할 때 아래와 같이 정리했다.

총 24 바이트가 들어가는데, 이 4바이트와 Chunk ID 의 4 바이트를 제외한 나머지 부분인 16 을 채워넣는다.

그리고, 이걸 코드에 넣을 때에는  16, 0, 0, 0, // Chunk Size 이런 식으로 넣는다.

빅 엔디안 이라면 앞에 2바이트 정도 더 추가해서 알아내야 되는 것에 비하면, 의외로 큰 도움이 된다는 것을 알 수 있다.

참고로, 리틀 엔디안 은 x86 가 리틀 엔디안 을 사용하기에 대부분 데스크톱은 리틀 엔디안 을 쓴다.

반대로 빅 엔디안 은 네트워크에서 주소를 표현하는 방식을 쓰이는데 이의 영향으로 많은 프로토콜과 파일 포맷들이 빅 엔디안 을 사용한다.

주로 모바일 프로세서에 쓰이는 ARM 아키텍쳐의 경우 성능 향상을 위해 빅 엔디안,리틀 엔디안을 선택할 수 있다고 한다.


참고로, WAV 는 빅 엔디안, 리틀 엔디안 을 모두 사용해서 작성해야 된다고 한다. Big Endian 으로 쓸 부분은 ‘R I F F’ , ‘W A V E’ , ‘F M T  ‘, ‘d a t a’ 의 4종류, 나머지는 리틀 엔디안 을 사용하는 것 같다.

Android – AudioRecord to WAV (오디오 녹음)

원래라면, 안드로이드에서 녹음은 MediaRecorder 면 된다.

하지만, SpeechRecognizer 등이나 Cloud Speech 가 들어간다면….

그 이유로는, 음성인식 자체도 마이크를 가져가고, MediaRecorder 도 마이크를 가져가니..

그나마 다행인 것으로는 Cloud Speech 를 사용하기 위해서 AudioRecord를 사용한다는 것이다.

그러면, 아래와 같은 꼼수를 할 수 있을 것 같다.

사용자가 먼저 말하면, 그걸 byte[] 로 읽어서 그대로 구글 서버로 보내고, outputStream 에 쓰면 되겠다.

1. AudioRecord 생성

일단 첫번째로, AudioRecord 를 생성한다.

단 기기마다 AudioRecord 가 지원하는 샘플링 레이트가 다를 수 있으므로, 아래와 같은 접근법을 사용한다.

후보군 (16000, 11025, 22050, 44100) 하나씩 for-loop 를 돌려, minBufferSize 가 ERROR_BAD_VALUE 가 나오지 않고, 성공적으로 initialization 에 성공한 오디오 레코드만 반환한다.

private AudioRecord createAudioRecord() {
        for (int sampleRate : SAMPLE_RATE_CANDIDATES) { // 후보군 for-loop
            final int sizeInBytes = AudioRecord.getMinBufferSize(sampleRate, CHANNEL, ENCODING); //CHANNEL_IN_MONO, ENCODING_PCM_16BIT, 샘플링 레이트로 최소 버퍼 사이즈 구함
            if (sizeInBytes == AudioRecord.ERROR_BAD_VALUE) { // 값이 비정상임
                continue; // 통과
            }
            final AudioRecord audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, sampleRate, CHANNEL, ENCODING, sizeInBytes); // AudioRecord init 시도
            if (audioRecord.getState() == AudioRecord.STATE_INITIALIZED) { // 성공?
                buffer = new byte[sizeInBytes]; // byte[] 버퍼 생성
                return audioRecord;
            } else {
                audioRecord.release(); // 실패했으니 릴리즈.
            }
        }
        return null;
    }

2. FileOutputStream 생성

try {
   outputStream = new FileOutputStream(lastPath);
   isRecording = true;
} catch (IOException e) {
   e.printStackTrace();
}

매우 평범한 코드므로 설명을 생략한다.

3. AudioRecord 버퍼 읽기

AudioRecord.start() 후에 Thread 를 돌리는데, 그 Thread 가 살아있는 도중은 while 로 계속 반복하는 구조로 짜면 된다.

@Override
        public void run() {
            while (running) {
                final int size = audioRecord.read(buffer, 0, buffer.length);
                processCapture(buffer, size);

이하 생략
private void processCapture(byte[] buffer, int status) {
       if (status == AudioRecord.ERROR_INVALID_OPERATION || status == AudioRecord.ERROR_BAD_VALUE)
           return;
       try {
           outputStream.write(buffer, 0, buffer.length);
       } catch (IOException e) {
           e.printStackTrace();
       }
   }
try {
   outputStream.close();
} catch (IOException e) {
    e.printStackTrace();
}

4. 이대로 끝..?

이대로 하면 나오는 건 PCM 파일이다.

그리고, 안드로이드에서 PCM을 재생하려면 일반 MediaPlayer보다 조금 귀찮다.

그래서, 목적대로 WAV 로 생성하려고 한다.

5. WAV 헤더

WAV 는 PCM 원본 데이터에 헤더 44바이트를 덮어씌우면 된다.

먼저 WAV 파일의 헤더 구조에 대해 알아보자.

크게 3 파트로 이루어진다.

Chunk ID / Chunk Size / Format

Chunk ID 에는 WAV 파일에 대한 고정값인 RIFF 라는 문자를 넣는다.

Chunk Size (Little Endian) 에는 파일 전체 사이즈 에서 RIFF 와 자기 자신(Chunk Size) 를 제외한 값인 전체 파일 크기 – 8 바이트 정도다.

Format 에는 WAVE 라는 문자가 ASCII 로 들어간다.

Chunk ID / Chunk Size / Audio Format / NumChannels / Sample Rate / Byte Rate / Block Align / Bits Per Sample

Chunk ID 에는 fmt 라는 고정값이 들어간다. ‘F’ ‘M’ ‘T’ ‘ ‘ 이다.

Chunk Size (Little Endian) 에는 총 24 바이트가 들어가는데, 이 4바이트와 Chunk ID 의 4 바이트를 제외한 나머지 부분인 16 을 채워넣는다.

Audio Format (Little Endian) 에는 PCM 일 경우 1, 이외의 경우 0인데 지금은 PCM이므로 1을 넣는다.

Number Of Channel (Little Endian) 에는 음성 파일의 채널 수를 넣는다.

Sample Rate (Little Endian) 에는 샘플링 레이트를 넣는다.

Byte Rate (Little Endian) 에는 Sample Rate * channels * (bitDepth / 8) 를 계산한 값을 넣는다.

channels, bitDepth 에는 아래 코드에서 언급하겠지만 AudioRecord 를 생성할 때 사용했던 CHANNEL, ENCODING 값이다.

Block Align (Little Endian) 에는 channel * (bitDepth / 8) 을 넣는다.

Bits Per Sample (Little Endian) 에는 bitDepth 를 넣는다.

Chunk ID / Chunk Size

Chunk ID 에는 data 를 넣는다.

Chunk Size 에는 뒤이어 나올 실제 데이터이다. 즉, 파일 사이즈 에서 헤더의 전체 크기인 44 바이트를 빼면 된다.

6. 위 설명을 바탕으로 생성해보자 : pre-Write Header

먼저, outputStream 인스턴스 생성 후 아래의 코드를 통과시킨다.

public static void writeWavHeader(OutputStream out, short channels, int sampleRate, short bitDepth) throws IOException {
     // WAV 포맷에 필요한 little endian 포맷으로 다중 바이트의 수를 raw byte로 변환한다.
     byte[] littleBytes = ByteBuffer
             .allocate(14)
             .order(ByteOrder.LITTLE_ENDIAN)
             .putShort(channels)
             .putInt(sampleRate)
             .putInt(sampleRate * channels * (bitDepth / 8))
             .putShort((short) (channels * (bitDepth / 8)))
             .putShort(bitDepth)
             .array();
     // 최고를 생성하지는 않겠지만, 적어도 쉽게만 가자.
     out.write(new byte[]{
             'R', 'I', 'F', 'F', // Chunk ID
             0, 0, 0, 0, // Chunk Size (나중에 업데이트 될것)
             'W', 'A', 'V', 'E', // Format
             'f', 'm', 't', ' ', //Chunk ID
             16, 0, 0, 0, // Chunk Size
             1, 0, // AudioFormat
             littleBytes[0], littleBytes[1], // Num of Channels
             littleBytes[2], littleBytes[3], littleBytes[4], littleBytes[5], // SampleRate
             littleBytes[6], littleBytes[7], littleBytes[8], littleBytes[9], // Byte Rate
             littleBytes[10], littleBytes[11], // Block Align
             littleBytes[12], littleBytes[13], // Bits Per Sample
             'd', 'a', 't', 'a', // Chunk ID
             0, 0, 0, 0, //Chunk Size (나중에 업데이트 될 것)
     });
 }

channels 에는 CHANNEL_IN_MONO (1) , sampleRate 에는 AudioRecord.getSampleRate(), bitDepth 에는 ENCODING_PCM_16BIT (16) 을 각각 넣고 넘긴다.

그리고 위에서 언급되었던 Little endian 을 사용해서 raw byte 를 만들고, 각각 헤더 데이터를 채워나간다.

단, 녹음 전이기 때문에 size 는 없어, 0 을 넣는다.

헤더를 쓰고, outputStream 에 AudioRecord로부터 읽은 buffer 를 쓴다.

7. 위 설명을 바탕으로 생성해보자 : update Header

public static void updateWavHeader(File wav) throws IOException {
        byte[] sizes = ByteBuffer
                .allocate(8)
                .order(ByteOrder.LITTLE_ENDIAN)
                // 아마 이 두 개를 계산할 때 좀 더 좋은 방법이 있을거라 생각하지만..
                .putInt((int) (wav.length() - 8)) // ChunkSize
                .putInt((int) (wav.length() - 44)) // Chunk Size
                .array();
        RandomAccessFile accessWave = null;
        try {
            accessWave = new RandomAccessFile(wav, "rw"); // 읽기-쓰기 모드로 인스턴스 생성
            // ChunkSize
            accessWave.seek(4); // 4바이트 지점으로 가서
            accessWave.write(sizes, 0, 4); // 사이즈 채움
            // Chunk Size
            accessWave.seek(40); // 40바이트 지점으로 가서
            accessWave.write(sizes, 4, 4); // 채움
        } catch (IOException ex) {
            // 예외를 다시 던지나, finally 에서 닫을 수 있음
            throw ex;
        } finally {
            if (accessWave != null) {
                try {
                    accessWave.close();
                } catch (IOException ex) {
                    // 무시
                }
            }
        }
    }

outputStream 을 닫은 뒤에 해당 파일을 넘기면 바이트를 계산해서 넘길 것이다.

8. 주의점

WAV 파일은 32-byte 기반이기 때문에, 4GB 이상은 쓰지를 못하는 것 같다.

그리고, 딱히 프로젝트에서 녹음을 한번에 길게 할 필요가 없기 때문에 특별히 대응은 하지 않았다.

정리

도중 WAV Header 가 나올 때 이해하기 위해 머리가 터질 뻔 했지만, 의외로 쉽게 구해낼 수 있었다.

원래라면 AudioRecord 에서 바로 Lame MP3 로 통과시켜 mp3 을 만들어내려고 했지만 구글 쪽에서는 byte[] 를 받고, Lame MP3 에서는 short[] 를 받았기 때문에 호환이 잘 안되었던 문제가 있었다.

하지만 일단 AudioRecord 에서 나온 바이트를 PCM 으로 그대로 저장이 가능하기에, 그 PCM 에 헤더를 붙여서 나오는 WAV 를 AndroidAudioConverter 등에 통과 시키면 MP3 가 나오긴 한다(…

어째 원래라면 이러면 안되야 겠지만 뭔가 불가항력이란 느낌이라고 해야되나.. 대충 비슷할 것이다.