본문 바로가기
개발/Android

[Open Source] AudioTrack 소개 및 PCM 파일 재생

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

시작

AudioTrack은 PCM 데이터를 이용해 실시간 오디오 재생이 가능하도록 하는 Android API입니다. 앱의 메모리에서 직접 오디오를 재생할 수 있는 방법 중 하나이기에 실시간 오디오 처리가 필요한 앱에 적합한 API라고 할 수 있습니다. PCM 파일 재생을 위해선 녹음 기능이 필요하기 때문에 이전 PCM 파일로 녹음하기 게시물을 진행하시고 오셔야 이해가 편합니다.

 

AudioTrack이란?

AudioTrack은 Object 클래스를 상속 받고 있으며 AudioRouting, VolumeAutomation 을 구현하고 있습니다.

AudioTrack은 자바 응용프로그램에 대한 단일 오디오 리소스를 관리하고 재생합니다. 재생을 위해 PCM 오디오 버퍼를 오디오 싱크로 스트리밍할 수 있습니다. 이것은 write(byte[], int, int), write(short[], int, int), and write(float[], int, int, int) 방법 중 하나를 사용해 데이터를 AudioTrack 객체에 “pushing” 를 통해 달성할 수 있습니다.

AudioTrack 인스턴스는 두 가지 모드로 작동할 수 있습니다: 정적 또는 스트리밍

스트리밍 모드에선 어플리케이션은 write() 함수를 사용해 AudioTrack에 지속적으로 데이터 스트림 쓰기를 반복해야 합니다. 이것은 데이터가 Java 계층에서 Native 계층으로 전송되고 재생 대기열이 있을 때에는 차단 및 반환됩니다. 스트리밍 모드는 다음과 같은 오디오 블록을 재생할 때 유용합니다.

  • 재생 가능한 시간이 메모리에 넣기 너무 큰 경우
  • 오디오 데이터 특성(높은 샘플링 속도, 샘플당 비트 수 등)으로 인해 메모리에 넣기 너무 큰 경우
  • 이전에 대기 중이던 오디오가 재생 중에 수신되거나 생성된 경우 (무슨 말인지 모르겠음..)

정적 모드에서는 (스트리밍과 다르게) 사용 가능한 메모리이며 가능한 한 최소의 지연 시간으로 재생해야 하는 길지 않은 소리를 처리할 때 사용해야 합니다. 따라서 정적 모드는 가능한 최소의 오버헤드로 자주 재생되는 UI 및 게임 사운드에 적합합니다.

생성 시 AudioTrack 객체는 관련된 오디오 버퍼를 초기화합니다. 생성 중에 지정된 버퍼 크기에 따라 데이터가 부족해지기 전까지 AudioTrack이 얼마나 재생될지 시간이 결정됩니다. 정적 모드를 사용하는 경우, 그 크기는 재생할 수 있는 사운드의 최대 크기입니다. 스트리밍 모드의 경우는 전체 버퍼의 크기보다 작거나 같은 chunk 사이즈의 크기로 오디오 싱크에 기록됩니다. AudioTrack은 최종 버전이 아니기 때문에 하위 클래스를 허용하지만 이런 사용은 권장되지 않습니다.

 

 

AudioTrack 객체 생성

기존에 AudioTrack 생성자로 객체를 생성하는 방법은 Deprecated 되어 아래처럼 AudioTrack Builder를 이용해 생성 해줘야 합니다. (물론 생성자로 객체를 생성할 방법이 있긴 있음: 참조)

private void play(){
    ...
    minimumBufferSize = AudioTrack.getMinBufferSize(SAMPLING_RATE_IN_HZ, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT);
    if(audioTrack==null){
        audioTrack = new AudioTrack.Builder()
                .setAudioAttributes(new AudioAttributes.Builder()
                        .setUsage(AudioAttributes.USAGE_MEDIA)
                        .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                        .build())
                .setAudioFormat(new AudioFormat.Builder()
                        .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
                        .setSampleRate(SAMPLING_RATE_IN_HZ)
                        .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
                        .build())
                .setTransferMode(AudioTrack.MODE_STREAM)
                .setBufferSizeInBytes(minimumBufferSize)
                .build();
    }
    ...
}

 

setAudioAttributes

재생되는 Audio의 속성을 정하는 함수로 자세한 것은 AudioAttributes을 참조하세요.

Parameters  
attributes AudioAttributes: 재생할 오디오 데이터를 설명하는 null이 아닌 AudioAttributes 인스턴스입니다.

 

setAudioFormat

AudioTrack으로 재생될 오디오 데이터의 형식을 설정하는 함수. 인코딩, 채널 마스크, 샘플 레이트와 같은 오디오 형식 구성 방법은 AudioFormat.Builder를 참조하세요.

Parameters  
format AudioFormat: null이 아닌 AudioFormat 인스턴스입니다.

 

setTransferMode

오디오 데이터의 버퍼가 AudioTrack에서 Framework로 전송되는 모드를 설정합니다.

Parameters  
mode int: AudioTrack#MODE_STREAM, AudioTrack#MODE_STATIC 중 하나 입니다.

 

setBufferSizeInBytes

재생을 위해 오디오 데이터가 읽힐 총 크기(바이트)를 설정합니다. 만약 스트리밍 모드에서 사용하는 경우 이 크기는 파일의 크기보다 작거나 같은 chunk 사이즈로 버퍼를 설정해야 합니다. 스트리밍 모드에서 AudioTrack 생성을 위한 최소 버퍼 사이즈를 결정하고 싶으면 AudioTrack.getMinBufferSize(int, int, int)를 참조하시면 됩니다. 만약 정적 모드에서 사용하려면 재생될 음악 파일의 최대 크기로 정하면 됩니다.

Parameters  
bufferSizeInBytes int: 0 또는 그것 보다 큰 값

 

PCM 파일 재생하기

private void play(){
    ...
    ExecutorService service = Executors.newSingleThreadExecutor();
    service.execute(new Runnable() {
        @Override
        public void run() {
            byte[] buffer = new byte[minimumBufferSize];
            FileInputStream fis = null;
            try {
                fis = new FileInputStream(audioFile);
            }catch (FileNotFoundException e) {
                e.printStackTrace();
            }
            DataInputStream dis = new DataInputStream(fis);
            try {
                dis.skip(position); // 위치 스킵
            } catch (IOException e) {
                e.printStackTrace();
            }
            audioTrack.play();  // write 하기 전에 play 를 먼저 수행해 주어야 함
            while (btnPlay.getTag().toString().contentEquals("play")){
                try {
                    int ret = dis.read(buffer, 0, minimumBufferSize);
                    if(ret <= 0) {
                        new Handler(Looper.getMainLooper()).post(new Runnable() {
                            @Override
                            public void run() {
                                stop();
                            }
                        });
                        break;
                    }
                    audioTrack.write(buffer, 0, ret);
                    position += minimumBufferSize; // AudioTrack의 위치 저장
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    });
}
  1. UI 쓰레드의 부하를 줄이기 위해 서브 스레드를 생성
  2. PCM 파일을 FileInputStream으로 불러와 DataInputStream에 넣음
  3. 만약 오디오의 위치 값이 0이 아닌 경우(정지된 상태), 스트림을 해당 위치까지 skip
  4. AudioTrack을 재생, write 하기 전에 play를 먼저 수행해 주어야 함
  5. 재생하는 동안 while문으로 계속 읽고 써줘야 함
  6. DataInputStream에서 데이터를 읽어옴
  7. 만약 읽어온 데이터가 -1인 경우(스트림 끝에 도달한 경우) stop 함수를 호출
  8. 읽어온 데이터를 AudioTrack 객체에 write 해줌
  9. position의 위치는 chuck 사이즈만큼 계속 증가시켜줌, AudioTrack의 위치 저장

 

일시 정지, PAUSE

private void pause(){
    if(audioTrack!=null) {
        ...
        audioTrack.pause();
    }
}

AudioTrack 의 pause 함수를 통해 일시 정지 시킬 수 있다.

 

중단, STOP, RELEASE

private void stop(){
    if(audioTrack!=null) {
        ...
        audioTrack.stop();
        audioTrack.release();
        audioTrack = null;
        position = 0;
    }
}
  1. AudioTrack을 stop과 relase를 통해 중단
  2. AudioTrack 객체를 null로 초기화
  3. position은 0으로 초기화

 

전체 코드

import androidx.appcompat.app.AppCompatActivity;

import android.content.Context;
import android.media.AudioAttributes;
import android.media.AudioFormat;
import android.media.AudioManager;
import android.media.AudioRecord;
import android.media.AudioTrack;
import android.media.MediaRecorder;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.widget.Button;
import android.widget.Toast;

import java.io.DataInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.Buffer;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";
    private Button btnRecord, btnPlay;
    private static final int SAMPLING_RATE_IN_HZ = 44100;
    private AudioRecord audioRecord = null;
    private AudioTrack audioTrack = null;
    private File audioFile = null;
    private FileOutputStream fos;
    int minimumBufferSize;
    int position = 0;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

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

        btnPlay = findViewById(R.id.btn_play);
        btnPlay.setOnClickListener(v->{
            if(v.getTag().toString().equals("stop")){
                play();
            }
            else {
                pause();
            }
        });
    }

    private void play(){
        btnPlay.setTag("play");
        btnPlay.setText("중지");
        minimumBufferSize = AudioTrack.getMinBufferSize(SAMPLING_RATE_IN_HZ, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT);
        if(audioTrack==null){
            audioTrack = new AudioTrack.Builder()
                    .setAudioAttributes(new AudioAttributes.Builder()
                            .setUsage(AudioAttributes.USAGE_MEDIA)
                            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                            .build())
                    .setAudioFormat(new AudioFormat.Builder()
                            .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
                            .setSampleRate(SAMPLING_RATE_IN_HZ)
                            .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
                            .build())
                    .setTransferMode(AudioTrack.MODE_STREAM)
                    .setBufferSizeInBytes(minimumBufferSize)
                    .build();
        }

        ExecutorService service = Executors.newSingleThreadExecutor();
        service.execute(new Runnable() {
            @Override
            public void run() {
                byte[] buffer = new byte[minimumBufferSize];
                FileInputStream fis = null;
                try {
                    fis = new FileInputStream(audioFile);
                }catch (FileNotFoundException e) {
                    e.printStackTrace();
                }
                DataInputStream dis = new DataInputStream(fis);
                try {
                    dis.skip(position); // 위치 스킵
                } catch (IOException e) {
                    e.printStackTrace();
                }
                audioTrack.play();  // write 하기 전에 play 를 먼저 수행해 주어야 함
                while (btnPlay.getTag().toString().contentEquals("play")){
                    try {
                        int ret = dis.read(buffer, 0, minimumBufferSize);
                        if(ret <= 0) {
                            new Handler(Looper.getMainLooper()).post(new Runnable() {
                                @Override
                                public void run() {
                                    stop();
                                }
                            });
                            break;
                        }
                        audioTrack.write(buffer, 0, ret);
                        position += minimumBufferSize; // AudioTrack 에 중지 상태 위치 저장
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        });
    }

    private void stop(){
        if(audioTrack!=null) {
            btnPlay.setTag("stop");
            btnPlay.setText("재생");
            audioTrack.stop();
            audioTrack.release();
            audioTrack = null;
            position = 0;
        }
    }

    private void pause(){
        if(audioTrack!=null) {
            btnPlay.setTag("stop");
            btnPlay.setText("재생");
            audioTrack.pause();
        }
    }

    private void startRecording(){
        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){
            btnRecord.setText("중지");
            btnRecord.setTag("recording");
            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 (btnRecord.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(){
        btnRecord.setTag("stop");
        btnRecord.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;
        }
    }
}

 

 

 

반응형