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 가 나오긴 한다(…

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