본문 바로가기
Android/Open Source

[Open Source] AudioRecord PCM 파일로 녹음하기

by 준그래머 2023. 7. 20.
반응형

시작

이전 게시물에서는 AudioRecord가 무엇 인지에 대해 정리했습니다. AudioRecord의 개념과 구현 방식, 장점 등에 대해 알아보았는데, 이번 포스팅에선 AudioRecord API를 이용해 pcm 확장자로 녹음하는 것에 대해 정리하려 합니다.

왜 하필 pcm 인가에 대해 궁금하실 수 있을 것 같아 말씀드리면 Andriod Developer에서 말하는 read 함수 모두 pcm 포맷을 파라미터로 넘기라고 되어 있으며 이것 때문인지 대다수의 예제 코드들이 pcm 확장자로 파일을 저장하는 것들이 였습니다. 따라서 기본기라고 생각하고 pcm 확장자 파일로 저장하는 포스팅을 한 후에 다른 확장자로 정의하는 것에 대해 정리할 예정입니다.

 

권한 요청

이번 포스팅에서는 오디오 데이터를 기록하고 기록된 데이터를 파일로 저장할 예정입니다. 따라서 아래와 같은 권한이 필요합니다.

  • 마이크 권한
  • 파일 및 미디어 권한
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

AndriodManifest.xml 에 코드를 정의해 줍니다.

 

UI 구현

녹음 기능을 제어할 Button 위젯의 tag 속성을 stop으로 정의하고 레이아웃에 추가해 줍니다.

<Button
    android:id="@+id/btn_recording"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:text="녹음"
    android:tag="stop"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

 

버튼 클릭 구현

버튼의 tag에 따라 녹음을 시작하고 정지할 예정입니다.

btnRecording = findViewById(R.id.btn_recording);
btnRecording.setOnClickListener(v->{
    if(v.getTag().toString().equals("stop")){
        // TODO 레코딩 시작
    }
    else {
        // TODO 레코딩 멈춤 및 저장
    }
});

 

AudioRecord 정의

아래 값을 파라미터로 보내 AudioRecord의 최소 버퍼 사이즈를 구합니다.

  • 샘플 레이트: 44100
  • 채널: 스테레오
  • 포맷: PCM 16 비트
private static final int SAMPLING_RATE_IN_HZ = 44100;
private void startRecording(){
    int minimumBufferSize = AudioRecord.getMinBufferSize(SAMPLING_RATE_IN_HZ, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT);
    if(minimumBufferSize < AudioRecord.SUCCESS) {
        Toast.makeText(this, "잘못된 크기: " + minimumBufferSize, Toast.LENGTH_SHORT).show();
        return;
    }
    ...
}

이때 버퍼의 크기가 0보다 작은 경우는 잘못된 크기를 구한 것이므로 녹음을 진행할 수 없습니다. (AudioRecord 소개의 getMinBufferSize 참조)

AudioRecord 객체가 null인 경우 새로 생성해 줍니다.

  • 오디오 장치: 마이크
  • 샘플 레이트: 44100
  • 채널: 스테레오
  • 포맷: PCM 16 비트
  • 최소 버퍼 사이즈: minimumBufferSize
private void startRecording(){
    ...
    if(audioRecord==null){
        audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLING_RATE_IN_HZ, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT, minimumBufferSize);
    }
    if(audioRecord.getState() == AudioRecord.STATE_INITIALIZED){
        btnRecording.setText("중지");
        btnRecording.setTag("recording");
        ...
    }
    ...
}

객체가 정상적으로 생성되면 state의 값은 AudioRecord.STATE_INITIALIZED로 반환됩니다. 버튼의 텍스트를 “중지”로 바꾸고 tag를 “recording” 으로 초기화 합니다.

 

AudioRecord 녹음 시작

녹음을 진행하기 위해 pcm 확장자 파일을 먼저 Downlad 디렉토리에 생성해 줍니다.

private File createNewAudioFile(){
    String path = Environment.getExternalStorageDirectory().toString() + "/Download/";
    String fileName = "AUDIO_" + System.currentTimeMillis();
    return new File(path, fileName + ".pcm");
}

private void startRecording(){
    ...
    if(audioRecord.getState() == AudioRecord.STATE_INITIALIZED){
        ...
        File audioFile = createNewAudioFile();
        ExecutorService service = Executors.newSingleThreadExecutor();
        service.execute(new Runnable() {
            @Override
            public void run() {
                byte[] buffer = new byte[minimumBufferSize];
                try {
                    fos = new FileOutputStream(audioFile);
                    audioRecord.startRecording();
                    while (btnRecording.getTag().toString().contentEquals("recording")){
                        audioRecord.read(buffer, 0, minimumBufferSize);
                        fos.write(buffer);
                    }`
                } catch (IOException e) {
                    e.printStackTrace();
                    stopRecording();
                }
            }
        });
    }
    ...
}

메인 쓰레드를 이용해 파일에 바이트를 덮어 씌우면 UI가 멈추는 현상이 발생하기 때문에 IO 쓰레드를 하나 생성해주고 거기서 오디오 파일을 저장해줘야 합니다.

먼저 byte 배열의 buffer 객체를 minimumBufferSize의 길이로 초기화해주고 아까 생성한 파일을 이용해 FileOutputStream을 초기화해 줍니다.

초기화 과정에서 IOException이 발생하지 않았다면 오디오 녹음을 실행하고 AudioRecord에서 오디오 데이터를 buffer 객체에 담고 그 데이터들을 다시 FileOutputStream 에 write 합니다.

 

AudioRecord 녹음 중단

중지 버튼을 클릭하면 tag를 “stop”으로 텍스트를 “녹음”으로 변경해 줍니다.

private void stopRecording(){
    btnRecording.setTag("stop");
    btnRecording.setText("녹음");
    if(audioRecord!=null && audioRecord.getState() != AudioRecord.STATE_UNINITIALIZED){
        audioRecord.stop();
        audioRecord.release();
        audioRecord = null;
    }
    if(fos != null){
        try {
            fos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        fos = null;
    }
}

AudioRecord 객체가 null이 아니고 상태가 AudioRecord.STATE_UNINITIALIZED 가 아닌 경우에만 audioRecord를 멈추고 해제시킨 뒤 null로 초기화 합니다. FileOutputStream은 null이 아니면 닫아 줍니다.

 

전체 코드

import androidx.appcompat.app.AppCompatActivity;
import android.media.AudioFormat;
import android.media.AudioRecord;
import android.media.MediaRecorder;
import android.os.Bundle;
import android.os.Environment;
import android.widget.Button;
import android.widget.Toast;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class MainActivity extends AppCompatActivity {

    private Button btnRecording;
    private static final int SAMPLING_RATE_IN_HZ = 44100;
    private AudioRecord audioRecord = null;
    private FileOutputStream fos;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        btnRecording = findViewById(R.id.btn_recording);
        btnRecording.setOnClickListener(v->{
            if(v.getTag().toString().equals("stop")){
                startRecording();
            }
            else {
                stopRecording();
            }
        });
    }

    private void startRecording(){
        int minimumBufferSize = AudioRecord.getMinBufferSize(SAMPLING_RATE_IN_HZ, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT);
        if(minimumBufferSize < AudioRecord.SUCCESS) {
            Toast.makeText(this, "잘못된 크기: " + minimumBufferSize, Toast.LENGTH_SHORT).show();
            return;
        }
        if(audioRecord==null){
            audioRecord = new AudioRecord(MediaRecorder.AudioSource.MIC, SAMPLING_RATE_IN_HZ, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT, minimumBufferSize);
        }
        if(audioRecord.getState() == AudioRecord.STATE_INITIALIZED){
            btnRecording.setText("중지");
            btnRecording.setTag("recording");
            File audioFile = createNewAudioFile();
            ExecutorService service = Executors.newSingleThreadExecutor();
            service.execute(new Runnable() {
                @Override
                public void run() {
                    byte[] buffer = new byte[minimumBufferSize];
                    try {
                        fos = new FileOutputStream(audioFile);
                        audioRecord.startRecording();
                        while (btnRecording.getTag().toString().contentEquals("recording")){
                            audioRecord.read(buffer, 0, minimumBufferSize);
                            fos.write(buffer);
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                        stopRecording();
                    }
                }
            });
        }
        else{
            Toast.makeText(this, "잘못된 상태:" + audioRecord.getState(), Toast.LENGTH_SHORT).show();
            stopRecording();
        }
    }

    private File createNewAudioFile(){
        String path = Environment.getExternalStorageDirectory().toString() + "/Download/";
        String fileName = "AUDIO_" + System.currentTimeMillis();
        return new File(path, fileName + ".pcm");
    }

    private void stopRecording(){
        btnRecording.setTag("stop");
        btnRecording.setText("녹음");
        if(audioRecord!=null && audioRecord.getState() != AudioRecord.STATE_UNINITIALIZED){
            audioRecord.stop();
            audioRecord.release();
            audioRecord = null;
        }
        if(fos != null){
            try {
                fos.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
            fos = null;
        }
    }
}

 

반응형