스마트기기개발관련/안드로이드 개발

안드로이드 사운드 관련

AlrepondTech 2013. 2. 26. 10:30
반응형



출처: http://blog.naver.com/anwun?Redirect=Log&logNo=113823272


비디오 ref > http://blog.naver.com/lcblue/100112585951

ref  >  http://www.winapi.co.kr/android/annex/18-1.htm

1.오디오

1.MediaPlayer

비싼 스마트폰을 사 놓고도 막상 전화 기능만 사용하는 사람들이라도 최소한 음악은 듣는다. 스마트폰의 주된 구입 목적 중 하나가 폰이랑 MP3랑 둘 다 들고 다니기 싫어서인 경우가 많으며 여기에 동영상 기능까지 잘 활용하면 PMP로도 거뜬히 사용할 수 있다. DMB도 물론 아주 실용적인 기능이다. 스마트폰은 전화 기능만큼이나 멀티미디어 기능의 비중이 높다.

근래의 고성능 스마트폰이 제공하는 멀티미디어 기능은 거의 PC 수준에 육박한다. 안드로이드도 물론 멀티미디어 기능이 잘 구비되어 있다. 그 핵심은 MediaPlayer 클래스인데 오디오와 비디오를 모두 지원하며 다양한 소스의 미디어를 재생할 수 있다. 재생 가능한 미디어 소스는 다음과 같다.

 

① 실행 파일에 내장된 리소스. 주로 게임의 효과음으로 사용된다.

② SD 카드에 파일 형태로 저장된 미디어. 가장 일반적인 예라고 할 수 있다.

③ 네트워크로 전송되는 스트림. 대용량의 음악이나 동영상을 즉시 감상할 수 있다.

 

네트워크가 기본 지원되는 휴대폰은 로컬의 파일 뿐만 아니라 전세계의 모든 미디어를 재생할 수 있는 셈이다. 공식적으로 재생 가능한 포맷은 다음과 같은데 대중적인 포맷들은 거의 다 지원된다. 이외에 장비에 추가로 설치된 코덱에 따라 지원 포맷이 늘어나기도 하는데 어떤 장비는 윈도우즈의 WMA와 WMV를 재생하며 디빅스도 별도의 변환없이 바로 볼 수 있다.

 

종류

포맷

오디오

WAV, MP3(8~320Kbps), MIDI, OGG, 3GP

비디오

H263, H264, Mpeg4

 

지원 소스나 포맷이 다양하고 기능도 많은데다 외부 파일이나 네트워크를 액세스하므로 사용 방법은 그다지 간단하지 않다. 정확한 절차대로 사용해야 하며 예외가 발생할 확률도 높아서 에러 처리도 섬세해야 한다. 상세한 절차는 다음 항에서 체계적으로 연구해 보기로 하고 일단은 간단한 사용법부터 연구해 보자. 생성자는 디폴트만 제공되며 인수는 받아들이지 않고 객체만 만든다.

 

public MediaPlayer ()

 

객체만 생성된 상태에서는 재생할 대상이 없으므로 아무 것도 할 수 없으며 재생할 미디어를 전달해야 한다. 두 가지 방법이 있는데 첫 번째는 다음 메서드를 호출하는 것이다. 스트림의 종류에 따라 여러 버전으로 오버로딩되어 있다.

 

void setDataSource (String path)

void setDataSource (Context context, Uri uri)

void setDataSource (FileDescriptor fd, [long offset, long length])

 

로컬 파일이나 Uri 객체로부터 원격지의 미디어를 연다. 리턴값은 없으며 에러 발생시 예외가 리턴되는데 이 예외는 반드시 처리해야 한다. 스트림을 열었다고 해서 바로 재생할 수는 없으며 약간의 준비가 필요하다. 예를 들어 동영상의 경우 필요한 코덱을 찾고 원활한 재생을 위해 얼마간의 버퍼를 할당해야 할 것이다. 대용량 스트림인 경우 상당한 시간이 걸릴 수 있으므로 오픈 직후 자동으로 준비 상태가 되지는 않으며 다음 메서드를 호출해야 한다.

 

void prepare ()

void prepareAsync ()

 

prepare 메서드는 동기적으로 준비를 하며 준비가 끝나면 리턴한다. 만약 준비 시간이 아주 오래 걸린다면 비동기적으로 동작하는 prepareAsync 메서드를 호출하고 콜백을 통해 준비 완료를 통보받아야 한다. 준비 상태가 되면 이후 바로 재생 가능하다. 객체 생성, 스트림 열기, 준비 과정을 거쳐야 하므로 상당히 번거로운데 두번째 방법은 좀 더 단순하다. 다음 정적 메서드를 호출하면 모든 과정이 내부에서 수행된다.

 

static MediaPlayer create (Context context, int resid)

static MediaPlayer create (Context context, Uri uri, [SurfaceHolder holder])

 

create 메서드는 리소스로부터 스트림을 열 수 있으나 파일을 열지는 못한다. 리소스의 미디어는 보통 크기가 작으므로 오픈 직후 자동으로 준비 상태가 되며 바로 재생 가능하다. 에러 발생시는 예외를 던지는 대신 null을 리턴한다. 간단한 효과음을 재생할 때는 이 메서드를 호출하는 것이 훨씬 더 간편하다. 다음은 재생 관련 메서드이다.

 

void start ()

void stop ()

void pause ()

 

이름이 너무 직관적이라 재생을 시작, 정지, 일시 중지한다는 설명은 굳이 하지 않아도 될 정도다. start 메서드는 재생을 시작한 후 즉시 리턴하므로 사운드 재생중에도 다른 작업을 할 수 있다. 재생이 시작되면 스트림의 끝까지 재생한 후 자동으로 멈춘다. 만약 반복적으로 재생하려면 다음 메서드로 반복 지정한다.

 

void setLooping (boolean looping)

boolean isLooping ()

 

setLooing(true)를 호출해 두면 한 스트림을 계속 반복하는데 게임의 배경 음악 재생용으로 적합하다. MediaPlayer를 다 사용한 후에는 다음 메서드로 정리한다.

 

void release ()

void reset ()

 

release는 객체를 완전히 파괴하여 더 이상 사용할 수 없는 상태로 해제한다. 음악을 재생하는 중에도 release는 언제든지 호출 가능하다. reset은 초기화되지 않은 처음 상태로 객체를 되돌리며 이후 재초기화하여 다시 사용할 수 있다는 점에서 release와 다르다. 사운드를 재생하는 두 가지 경로를 정리해 보면 다음과 같다.


create 정적 메서드로 생성하는 것이 훨씬 더 간편해 보이지만 대용량 미디어에는 효율이 좋지 않으므로 아주 짧은 미디어에만 사용하는 것이 바람직하다. 그럼 이제 각 방법으로 사운드를 재생하는 예제를 만들어 보자.

 

mm_MPTest

public class mm_MPTest extends Activity {

     MediaPlayer mPlayer;

 

     public void onCreate(Bundle savedInstanceState) {

          super.onCreate(savedInstanceState);

          setContentView(R.layout.mm_mptest);

 

          // 리소스 재생

          findViewById(R.id.btn1).setOnClickListener(new Button.OnClickListener() {

              public void onClick(View v) {

                   MediaPlayer player = MediaPlayer.create(mm_MPTest.this, R.raw.dingdong);

                   player.start();

              }

          });

 

          // 파일 재생

          findViewById(R.id.btn2).setOnClickListener(new Button.OnClickListener() {

              public void onClick(View v) {

                   MediaPlayer player = new MediaPlayer();

                   try {

                        player.setDataSource("/sdcard/eagle5.mp3");

                        player.prepare();

                        player.start();

                   } catch (Exception e) {

                        Toast.makeText(mm_MPTest.this, "error : " + e.getMessage(), 0).show();

                   }

              }

          });

 

          // 스트림 재생

          findViewById(R.id.btn3).setOnClickListener(new Button.OnClickListener() {

              public void onClick(View v) {

                   MediaPlayer player = new MediaPlayer();

                   try {

                        Uri uri = Uri.parse("http://www.winapi.co.kr/data/saemaul1.mp3");

                        player.setDataSource(mm_MPTest.this, uri);

                        player.prepare();

                        player.start();

                   } catch (Exception e) {

                        Toast.makeText(mm_MPTest.this, "error : " + e.getMessage(), 0).show();

                   }

              }

          });

 

          // 미리 준비된 객체로 재생

          mPlayer = MediaPlayer.create(this, R.raw.dingdong);

          findViewById(R.id.btn4).setOnClickListener(new Button.OnClickListener() {

              public void onClick(View v) {

                   mPlayer.seekTo(0);

                   mPlayer.start();

              }

          });

 

          // 준비하지 않은 상태로 재생

          findViewById(R.id.btn5).setOnClickListener(new Button.OnClickListener() {

              public void onClick(View v) {

                   MediaPlayer player = new MediaPlayer();

                   try {

                        player.setDataSource("/sdcard/eagle5.mp3");

                        player.start();

                   } catch (Exception e) {

                        Toast.makeText(mm_MPTest.this, "error : " + e.getMessage(), 0).show();

                   }

              }

          });

 

          // 다른 파일 열기

          findViewById(R.id.btn6).setOnClickListener(new Button.OnClickListener() {

              public void onClick(View v) {

                   MediaPlayer player = MediaPlayer.create(mm_MPTest.this, R.raw.dingdong);

                   //player.reset();

                   try {

                        player.setDataSource("/sdcard/eagle5.mp3");

                        player.prepare();

                        player.start();

                   } catch (IllegalArgumentException e) {

                        Toast.makeText(mm_MPTest.this, "IllegalArgumentException", 0).show();

                   } catch (IllegalStateException e) {

                        Toast.makeText(mm_MPTest.this, "IllegalStateException", 0).show();

                   } catch (IOException e) {

                        Toast.makeText(mm_MPTest.this, "IOException", 0).show();

                   }

              }

          });

     }

 

     public void onDestroy() {

        super.onDestroy();

        if (mPlayer != null) {

          mPlayer.release();

          mPlayer = null;

        }

    }

}

 

레이아웃에는 버튼들만 여러 개 배치되어 있으며 각 버튼의 클릭 리스너에서 사운드를 재생한다. 재생할 리소스는 res/raw 폴더에 미리 넣어 두었으며 스트리밍은 웹서버에 업로드해 두었다. 로컬 파일은 배포 예제에 포함되어 있지 않으므로 직접 준비하되 번거롭다면 다음 압축 파일을 다운로드받아 SD 카드의 루트 폴더에 복사하면 된다.

 

http://www.winapi.co.kr/data/testmedia.zip

 

에뮬레이터의 SD 카드가 큰 파일을 잘 지원하지 못하므로 용량이 작은 테스트 미디어를 준비했다. 여러분들이 가진 최신 가요 파일을 사용해도 무방하다. 각 버튼의 클릭 리스너에서 MediaPlayer 객체를 지역적으로 생성하므로 리스너 안쪽의 코드만 순서대로 살펴 보면 된다.


첫 번째 버튼을 누르면 리소스의 wav 파일을 읽어 "딩동"이라는 짧은 효과음을 낸다. 재생하고 싶은 파일을 res/raw 폴더에 복사해 두고 create 정적 메서드로 리소스를 읽은 후 start 메서드만 호출하면 된다. 다 사용한 후 release를 해야 하나 지역 객체이므로 release할 시점이 따로 없어 가비지 컬렉터가 정리하도록 내버려 두는 수밖에 없다. 단발적인 효과음을 낼 때는 이 방법도 나쁘지는 않다.

두번째 버튼은 MP3 파일을 읽어 재생하는데 파일의 경로를 정확하게 전달해야 한다. 이 예제는 파일 위치와 이름을 하드코딩해 두었으므로 이 파일을 복사해 두거나 경로를 편집해야 제대로 실행될 것이다. 객체 생성 후 setDataSource 메서드로 파일을 읽어 들이고 준비를 한 후 start 메서드를 호출한다. 외부 파일이므로 리소스보다 훨씬 더 긴 사운드도 재생할 수 있되 예외 발생 가능성이 농후하므로 반드시 try 블록으로 감싸야 한다. 문법이 강제적으로 요구하므로 생략할 수 없다.

세번째 버튼은 웹에서 사운드 파일을 다운로드 받아 재생한다. setDataSource 메서드의 인수가 경로 문자열에서 Uri로 바뀐 것만 다르다. 로컬에 파일이 없어도 인터넷에 연결되어 있기만 하면 다운로드받아 재생할 수 있다. 스트리밍 파일은 미리 업로드해 놓았는데 혹시 이 사이트가 망했으면 주소를 바꿔 보아라. 나머지 새 버튼의 코드는 약간의 문제가 있는데 다음 항에서 좀 더 연구해 보자.

2.상태의 변화

앞 예제는 사운드를 재생하는 여러 가지 방법의 원론적인 절차를 보여주기 위해 의도적으로 작성한 것이다. 섬세한 예외 처리는 예제라서 생략했다 치더라도 몇 가지 치명적인 문제가 있다. 우선 MediaPlayer 객체가 메서드의 지역 객체로 선언되었다는 것이 문제이다. 다 사용한 후 자동으로 회수되기는 하지만 너무 빠른 속도로 생성하면 다수의 객체가 공존하게 되며 이때 하드웨어를 두고 경쟁하게 된다.

첫 번째 버튼은 사운드가 짧아 잘 느낄 수 없지만 두 번째 버튼을 여러 번 눌러 보면 매번 새 객체가 생성되어 처음부터 재생을 하므로 독수리 5형제가 돌림 노래로 재생된다. 재생을 위해 스레드를 생성하므로 액티비티가 종료되어도 계속 재생되며 장비를 끄지 않는 한 멈출 방법이 없다. 액티비티가 종료되는 시점에 release를 호출해야 하는데 지역 객체이다 보니 그럴 수 없으며 재생이 끝나는 시점에 정리를 할려고 해도 재생이 끝나는 시점을 통보받을 방법이 없다.


사운드의 길이가 짧을 때는 금방 회수가 되지만 길어지면 여러 개의 객체가 생성되며 이 상태를 방치하면 다운되기도 한다. 첫번째 버튼을 마구 눌러 보면 다운되어 버릴 것이다. 문법적으로 생성 가능한 객체 개수에는 제한이 없지만 하드웨어가 무한대의 객체를 다 지원할 수 없으므로 일정 개수 이상이 되면 null이 리턴된다. if (player == null) 조건문으로 점검할 수는 있으나 그 보다 더 좋은 방법은 멤버로 선언해 두고 한 객체를 계속 사용하는 것이다.

네 번째 버튼은 이 방법대로 사운드를 재생한다. MediaPlayer 객체를 멤버로 선언해 두고 onCreate에서 미리 생성 및 준비까지 완료해 놓았다. 버튼을 누르면 seekTo 메서드로 재생 위치를 리셋하고 start를 호출한다. 한 객체로 사운드를 재생하므로 여러 음을 동시에 출력할 수는 없지만 최소한 다운되지는 않는다. 또 onDestroy에서 release를 호출하여 정리할 수 있으므로 액티비티가 종료되면 재생하던 사운드도 자동으로 정지된다.

MediaPlayer는 미디어가 재생되는 동안 장기간 존속하며 미디어나 외부 환경에 영향을 받으므로 에러 상황을 관리하기 위해 스스로의 상태를 유지한다. 현재 상태에 따라 호출 가능한 메서드가 달라지며 특정 메서드를 호출하면 상태가 바뀌기도 한다. 임의의 동작이 언제나 가능한 것은 아니고 현재 상태에 따라 엄격한 제한이 가해지므로 상태에 따라 섬세한 관리가 필요하다.

예를 들어 미디어를 열지도 않았는데 재생을 시작할 수는 없고 재생중에 갑자기 다른 미디어로 바꾸는 것도 안된다. 왜 상태가 필요한지는 직관적으로 쉽게 이해가 될 것이다. MediaPlayer의 전체 상태와 각 메서드 호출에 의한 상태 변화도는 다음과 같다. 이해를 위해 꼭 필요한 부분만 간단하게 그렸는데 레퍼런스에는 좀 더 상세한 상태 변화도가 있으므로 참고하기 바란다.


객체를 처음 생성하거나 reset하면 Idle 상태로 시작되며 이 상태에서는 재생을 할 수 없다. 똑같은 Idle 상태라도 새로 생성된 객체와 reset된 객체의 동작이 조금 다른데 상태에 맞지 않은 메서드 호출시의 에러 처리가 다르다. 생성 직후에는 단순히 에러를 무시해 버리지만 reset된 객체는 Error 상태로 전환되며 onError 콜백이 호출된다.

Idel 상태에서 초기화를 하려면 setDataSource 메서드를 호출하여 미디어를 연다. 이 메서드는 Idle일 때만 호출할 수 있으며 재생중이거나 일시 정지된 상태에서 미디어를 교체하지는 못한다. 미디어를 연 후 prepare 메서드를 호출하여 준비 상태로 전환하며 재생전에 반드시 준비 상태여야 한다. create 정적 메서드로 객체를 생성하면 생성과 동시에 미디어를 열고 준비 상태로 시작한다. 준비 상태일 때 볼륨 조절, 반복 여부 등을 조정할 수 있다.

start 메서드를 호출하면 재생중(Started) 상태가 되며 이 상태에서 언제든지 정지, 일시 정지 가능하다. 일시 정지는 pause/start 메서드로 언제든지 토글 가능하다. 그러나 stop 메서드로 정지한 상태에서는 바로 재생 상태로 복귀할 수 없으며 다시 준비 과정을 거쳐야 한다. isPlaying 메서드는 현재 재생중인지를 조사하는데 일시 정지, 일시 중지 상태일 때는 false이며 재생중에는 true가 리턴된다. 다음 메서드들은 재생 길이를 조사하거나 위치를 조사 및 변경한다.

 

int getDuration ()

int getCurrentPosition ()

void seekTo (int msec)

 

getDuration은 총 재생 길이를 구하고 getCurrentPosition은 현재 재생 위치를 구한다. 주로 프로그래스 바 갱신에 사용되는데 둘 다 준비 상태 이후에는 언제든지 호출할 수 있다. seekTo는 재생 위치를 임의로 변경하는데 Prepared, Started, Paused, PalybackCompleted 상태에서 호출 가능하다. 재생중에도 즉시 다른 위치로 이동 가능하며 준비나 일시 정지된 상태에서도 시작 위치를 마음대로 바꿀 수 있지만 정지된(Stopped) 상태에서는 위치를 바꿀 수 없다.

MediaPlayer는 상태가 변경되거나 에러가 발생할 때 미리 등록된 콜백 메서드를 호출한다. 관심있는 사건에 대해 리스너를 등록해 놓으면 원하는 시점에 신호를 받을 수 있다. 물론 관심없는 이벤트에 대해서는 굳이 콜백을 등록하지 않아도 상관없되 대개의 경우 에러 콜백은 등록하는 것이 권장된다. 각 리스너 인터페이스에는 이벤트를 받는 메서드가 정의되어 있고 이벤트와 관련된 인수가 전달된다.

 

void setOnErrorListener (MediaPlayer.OnErrorListener listener)

void setOnPreparedListener (MediaPlayer.OnPreparedListener listener)

void setOnCompletionListener (MediaPlayer.OnCompletionListener listener)

void setOnBufferingUpdateListener (MediaPlayer.OnBufferingUpdateListener listener)

void setOnSeekCompleteListener (MediaPlayer.OnSeekCompleteListener listener)

 

onCompletion 콜백은 스트림을 끝까지 재생 했을 때 호출되며 이때 객체는 재생 완료(Playback Completed)상태이다. 이 콜백을 받았을 때 start 메서드를 호출하면 같은 미디어를 처음부터 다시 재생한다. 다음 미디어로 변경하려면 reset 하여 Idle 상태로 간 후 처음부터 다시 시작해야 한다. 앞에서도 얘기했다시피 실행중에 미디어를 오픈하는 setDataSource 메서드는 Idle 상태에서만 호출 가능하므로 reset을 해야만 미디어를 교체할 수 있다.

onBufferingUpdate 콜백은 스트리밍시에 버퍼에 새로운 데이터가 들어왔을 때 호출된다. 로컬 리소스나 파일을 읽을 때는 버퍼링을 하지 않으므로 이 콜백을 프로그래밍할 필요가 없다. onSeekComplete 콜백은 재생 위치 변경이 완료될 때 호출된다. seekTo 메서드가 동기적으로 동작하지 않으므로 변경 시점을 정확하게 알아 내려면 이 콜백이 필요하다.

이상으로 MediaPlayer 객체의 상태 변화와 각 상태 변화시 호출되는 콜백에 대해 연구해 보았는데 복잡해 보이지만 대부분 상식선에서 이해가 될 것이다. 상태를 지키지 않았을 때 어떤 결과가 초래되는지 몇 가지 테스트를 해 보자. 다섯 번째 버튼은 준비 과정을 생략하고 start 메서드를 바로 호출하는데 이 경우 사운드는 재생되지 않는다. 에러는 발생하지 않지만 상태를 지키지 않았기 때문에 무시당한다.

여섯 번째 버튼은 create 정적 메서드로 미디어를 연 상태에서 setDataSource 메서드로 다른 미디어를 다시 열었다. 준비된 상태에서는 재생만 가능하며 다른 미디어로 교체를 할 수 없으므로 상태를 위반한 것이며 예외 처리 구문으로 감싸 보면  IllegalStateException 예외가 발생한다. 사용하던 객체를 다른 용도로 재사용하려면 반드시 reset 메서드로 리셋한 후 재사용해야 한다. 주석으로 처리된 reset 문의 주석을 풀면 제대로 동작할 것이다.

지금 당장은 이 상태 도표가 눈에 들어오지 않겠지만 코드를 작성해 보면 왜 이런 상태 관리가 필요한지 이해할 수 있을 것이다. 하다가 뭔가 뜻하는대로 되지 않거나 오동작을 한다면 정확한 절차대로 코드를 작성했는지 이 도표를 보고 다시 점검해 보도록 하자. 상대 변화도 그림은 MediaPlayer의 거의 모든 것이 함축적으로 설명하고 있다.

3.뮤직 플레이어

MediaPlayer로 제작할 수 있는 응용 프로그램중에 가장 실용적이고도 대표적인 것이 MP3 플레이어이다. 다음 예제는 음악 재생기의 가장 기본적인 기능을 구현한다. SD 카드의 루트 디렉토리에서 MP3 파일 목록을 조사하여 차례대로 재생하며 재생 위치를 보여 주고 임의 위치로 이동하는 정도의 기능을 제공한다.

어디까지나 학습용으로 제작한 것이므로 에러 처리도 지극히 기본적인 것만 되어 있으며 파일 목록도 SD 카드의 루트 디렉토리로만 국한되어 있다. 따라서 이 예제를 테스트해 보려면 SD 카드에 미리 MP3 파일을 복사해 두어야 한다. 치렁 치렁한 장식과 복잡한 기능은 빼고 최대한 간단하게 작성했음에도 200줄이 넘는다. 복잡한 기능이 없고 약간의 주석이 달려 있어 분석해 보기는 어렵지 않을 것이다.

 

mm_PlayAudio

public class mm_PlayAudio extends Activity {

     ArrayList<String> mList;

     int mIdx;

     MediaPlayer mPlayer;

     Button mPlayBtn;

     TextView mFileName;

     SeekBar mProgress;

     boolean wasPlaying;

    

     public void onCreate(Bundle savedInstanceState) {

          super.onCreate(savedInstanceState);

          setContentView(R.layout.mm_playaudio);

         

          mList = new ArrayList<String>();

          mPlayer = new MediaPlayer();

 

          // SD 카드가 없을 시 에러 처리한다.

          String ext = Environment.getExternalStorageState();

          String sdPath;

          if (ext.equals(Environment.MEDIA_MOUNTED) == false) {

              Toast.makeText(this, "SD 카드가 반드시 필요합니다.", Toast.LENGTH_LONG).show();

              finish();

              return;

          }

 

          // SD 카드 루트의 MP3 파일 목록을 구한다.

          sdPath = Environment.getExternalStorageDirectory().getAbsolutePath();

          File sdRoot = new File(sdPath);

          FilenameFilter filter = new FilenameFilter() {

              public boolean accept(File dir, String name) {

                   return name.endsWith(".mp3");

              }

          };

          String[] mplist = sdRoot.list(filter);

          if (mplist.length == 0) {

              Toast.makeText(this, "재생할 파일이 없습니다.", Toast.LENGTH_LONG).show();

              finish();

              return;

          }

          for(String s : mplist) {

              mList.add(sdPath + "/" + s);

          }

          mIdx = 0;

 

          // 버튼들의 클릭 리스너 등록

          mFileName = (TextView)findViewById(R.id.filename);

          mPlayBtn = (Button)findViewById(R.id.play);

          mPlayBtn.setOnClickListener(mClickPlay);

          findViewById(R.id.stop).setOnClickListener(mClickStop);

          findViewById(R.id.prev).setOnClickListener(mClickPrevNext);

          findViewById(R.id.next).setOnClickListener(mClickPrevNext);

         

          // 완료 리스너, 시크바 변경 리스너 등록

          mPlayer.setOnCompletionListener(mOnComplete);

          mPlayer.setOnSeekCompleteListener(mOnSeekComplete);

          mProgress = (SeekBar)findViewById(R.id.progress);

          mProgress.setOnSeekBarChangeListener(mOnSeek);

          mProgressHandler.sendEmptyMessageDelayed(0,200);

         

          // 첫 곡 읽기 및 준비

          if (LoadMedia(mIdx) == false) {

              Toast.makeText(this, "파일을 읽을 수 없습니다.", Toast.LENGTH_LONG).show();

              finish();

          }

     }

 

     // 액티비티 종료시 재생 강제 종료

     public void onDestroy() {

        super.onDestroy();

        if (mPlayer != null) {

          mPlayer.release();

          mPlayer = null;

        }

    }

 

    // 항상 준비 상태여야 한다.

    boolean LoadMedia(int idx) {

          try {

              mPlayer.setDataSource(mList.get(idx));

          } catch (IllegalArgumentException e) {

              return false;

          } catch (IllegalStateException e) {

              return false;

          } catch (IOException e) {

              return false;

          }

          if (Prepare() == false) {

              return false;

          }

          mFileName.setText("파일 : " + mList.get(idx));

          mProgress.setMax(mPlayer.getDuration());

          return true;

    }

   

    boolean Prepare() {

          try {

              mPlayer.prepare();

          } catch (IllegalStateException e) {

              return false;

          } catch (IOException e) {

              return false;

          }

          return true;

    }

 

    // 재생 및 일시 정지

    Button.OnClickListener mClickPlay = new View.OnClickListener() {

          public void onClick(View v) {

              if (mPlayer.isPlaying() == false) {

                   mPlayer.start();

                   mPlayBtn.setText("Pause");

              } else {

                   mPlayer.pause();

                   mPlayBtn.setText("Play");

              }

          }

     };

 

     // 재생 정지. 재시작을 위해 미리 준비해 놓는다.

    Button.OnClickListener mClickStop = new View.OnClickListener() {

          public void onClick(View v) {

              mPlayer.stop();

              mPlayBtn.setText("Play");

              mProgress.setProgress(0);

              Prepare();

          }

    };

   

    Button.OnClickListener mClickPrevNext = new View.OnClickListener() {

          public void onClick(View v) {

              boolean wasPlaying = mPlayer.isPlaying();

             

              if (v.getId() == R.id.prev) {

                   mIdx = (mIdx == 0 ? mList.size() - 1:mIdx - 1);

              } else {

                   mIdx = (mIdx == mList.size() - 1 ? 0:mIdx + 1);

              }

             

              mPlayer.reset();

              LoadMedia(mIdx);

 

              // 이전에 재생중이었으면 다음 곡 바로 재생

              if (wasPlaying) {

                   mPlayer.start();

                   mPlayBtn.setText("Pause");

              }

          }

    };

 

    // 재생 완료되면 다음곡으로

    MediaPlayer.OnCompletionListener mOnComplete = new MediaPlayer.OnCompletionListener() {

          public void onCompletion(MediaPlayer arg0) {

              mIdx = (mIdx == mList.size() - 1 ? 0:mIdx + 1);

              mPlayer.reset();

              LoadMedia(mIdx);

              mPlayer.start();

          }

    };

 

    // 에러 발생시 메시지 출력

    MediaPlayer.OnErrorListener mOnError = new MediaPlayer.OnErrorListener() {

          public boolean onError(MediaPlayer mp, int what, int extra) {

              String err = "OnError occured. what = " + what + " ,extra = " + extra;

              Toast.makeText(mm_PlayAudio.this, err, Toast.LENGTH_LONG).show();

              return false;

          }

    };

 

    // 위치 이동 완료 처리

    MediaPlayer.OnSeekCompleteListener mOnSeekComplete = new MediaPlayer.OnSeekCompleteListener() {

          public void onSeekComplete(MediaPlayer mp) {

              if (wasPlaying) {

                   mPlayer.start();

              }

          }

    };

 

    // 0.2초에 한번꼴로 재생 위치 갱신

     Handler mProgressHandler = new Handler() {

          public void handleMessage(Message msg) {

              if (mPlayer == null) return;

              if (mPlayer.isPlaying()) {

                   mProgress.setProgress(mPlayer.getCurrentPosition());

              }

              mProgressHandler.sendEmptyMessageDelayed(0,200);

          }

     };

 

     // 재생 위치 이동

     SeekBar.OnSeekBarChangeListener mOnSeek = new SeekBar.OnSeekBarChangeListener() {

          public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {

              if (fromUser) {

                   mPlayer.seekTo(progress);

              }

          }

 

          public void onStartTrackingTouch(SeekBar seekBar) {

              wasPlaying = mPlayer.isPlaying();

              if (wasPlaying) {

                   mPlayer.pause();

              }

          }

 

          public void onStopTrackingTouch(SeekBar seekBar) {

          }

     };

}

 

 

레이아웃에는 파일을 표시하는 텍스트 뷰 하나, 재생 및 이전/다음 곡으로 이동하는 버튼 4개, 현재 재생 위치를 보여주는 시크 바 정도가 배치되어 있다. 시크바는 재생 위치를 이동하는 기능도 제공한다. 볼륨 조절 기능도 제공할만 하지만 하드웨어 볼륨키가 제공되므로 굳이 포함하지 않았다.


onCreate는 재생에 필요한 초기화를 수행한다. SD 카드의 루트 디렉토리에서 MP3 파일의 목록을 구하되 SD 카드가 없거나 MP3 파일이 없으면 정상적인 동작을 할 수 없으므로 에러 처리하고 종료한다. 목록 조사 후 첫 곡을 열고 재생 준비를 하는데 이 작업은 LoadMedia 메서드가 담당한다. LoadMedia는 미디어 파일을 열어 준비 상태로 만들어 주므로 이후 언제든지 재생을 시작할 수 있다. 그 외 파일의 제목을 출력하고 시크바에 재생 범위를 설정하는 작업도 한다.

Play 버튼은 isPlaying 메서드로 현재 상태를 조사한 후 재생 및 일시 정지를 토글한다. 한 버튼으로 두 가지 기능을 수행하므로 상태에 따라 버튼의 캡션을 적당히 변경한다. Stop 버튼은 재생을 중지하고 시크바를 초기화한다. 상태 전환도를 보면 Stop 상태에서는 start 메서드를 바로 호출할 수 없다고 되어 있으므로 반드시 prepare를 호출하여 준비 상태로 전환해야 한다. 그렇지 않으면 정지는 되지만 재시작을 할 수 없다.

Prev/Next 버튼은 현재 재생 위치인 mIdx를 증감시켜 이전/다음 곡을 재생한다. 재생중에 미디어를 변경하려면 반드시 reset한 후 새 미디어를 읽어야 한다. 재생중에 곡을 변경했으면 다음곡을 계속 재생하고 정지 상태였다면 미디어만 변경한 후 대기한다. 재생하던 미디어가 끝까지 재생 완료되었으면 자연스럽게 다음 곡으로 넘어가야 하는데 이 처리를 위해 완료 리스너를 설치하고 재생이 끝날 때 다음곡을 로드하여 계속 재생한다. 이전/다음으로 이동할 때는 끝에서 반대쪽으로 순환하도록 했다.


MediaPlayer는 재생 위치가 변경될 때 특별한 콜백을 호출하지 않는 대신 getCurrentPosition 메서드로 언제든지 재생 위치에 대한 정보를 조사할 수 있다. 따라서 핸들러로 타이머를 돌리며 주기적으로 현재 위치를 조사하여 시크바로 출력해야 한다. 재생 위치 표시가 수동이라 좀 불편하기는 하지만 대신 핸들러 호출 지연 시간을 조정함으로써 갱신 주기를 자유롭게 선택할 수 있다. 종료시에도 핸들러는 호출되는데 이때는 mPlayer가 유효한 상태인지를 반드시 점검해야 함을 주의하자.

사용자가 시크바를 클릭하면 재생 위치를 변경하되 소리가 끊어지는 것을 방지하기 위해 변경 직전에 재생을 잠시 중지하고 위치 변경 리스너에서 다시 재생을 시작해야 한다. seekTo 메서드가 동기적으로 동작하지 않으며 때로는 위치 변경에 상당한 시간이 걸릴 수도 있으므로 리스너 처리는 꼭 필요하다. 정지 상태에서 위치를 바꾼 것이라면 위치만 바꾸면 될 뿐 재생을 다시 시작할 필요는 없다. wasPlaying 필드는 위치 이동전에 재생 상태였는지를 기억한다.

onDestroy에서는 release를 호출하여 객체를 완전히 해제한다. release 메서드는 상태에 상관없이 언제든지 호출할 수 있는 메서드이며 재생중이라도 즉시 중지하고 객체를 해제한다. onPause에서는 재생을 정지하지 않는데 MP3 플레이어는 백그라운드에서도 계속 실행되어야 하므로 액티비티가 잠시 멈추었다고 해서 재생을 중지할 필요는 없다. 사용자가 Back 키를 눌러 프로그램을 명시적으로 종료했을 때만 객체를 해제하고 Home키를 눌렀을 때는 계속 재생해야 한다.

이상으로 지극히 간단한 MP3 플레이어를 제작해 봤는데 실용성을 높이려면 좀 더 개선이 필요하다. 파일 목록 위치가 고정되어 있는데 임의 위치의 파일도 재생 가능해야 하며 미디어 DB를 참조하는 방법도 있다. 에러 리스너는 토스트로 에러 내용만 출력했는데 좀 더 상세한 처리가 필요하다. UI도 너무 촌스러운데 좀 더 예쁘게 만들 수 있는 여지가 많으며 랜덤 재생이나 반복 재생 등의 기능도 필수적이다. 또 재생중에 전화가 걸려오는 상황도 잘 대처해야 한다. 위 예제를 완벽하게 이해한다면 추가 기능 작성에는 큰 어려움이 없을 것이다.

4.오디오 녹음

재생하는 것보다 실용성이 다소 떨어지기는 하지만 오디오 녹음도 가능하다. 스마트폰은 기본적으로 전화기이고 예외없이 마이크가 내장되어 있으므로 당연히 녹음을 할 수 있다. 안드로이드는 음성 녹음 및 영상 녹화를 위해 MediaRecorder 클래스를 제공한다. 사용하는 방법은 형제 클래스인 MediaPlayer와 거의 유사하다.

녹음 객체도 재생 객체와 마찬가지로 상태를 유지하며 상태별로 가능한 동작이 있고 불가능한 동작이 있다. 그러나 MediaPlayer에 비해 상태가 복잡하지 않으며 녹음은 재생에 비해 일회적인 경우가 많으므로 메서드를 순서대로 호출하기만 하면 별 문제없이 활용할 수 있다. 다음 순서대로 객체를 초기화하고 필요한 메서드만 호출하면 된다. 디폴트 생성자로 생성한 후 다음 메서드로 입력 소스를 지정한다.

 

void setAudioSource (int audio_source)

setVideoSource (int video_source)

 

입력 소스란 음성이나 영상을 어떤 장비로부터 받을 것인가를 지정하는 것이다. 오디오 소스로는 캠코더나 음성 인식 등이 있으나 현재 버전에서는 마이크(MIC)만 지원된다. 핸드폰에 내장된 마이크를 통해 음성을 입력받는다. 비디오 소스로는 카메라만 지정할 수 있다. 다음은 출력 파일의 포맷을 지정한다.

 

void setOutputFormat (int output_format)

 

MPEG-4, THREE_GPP 등의 포맷을 지정할 수 있는데 스마트폰에서는 가급적이면 3GP 포맷을 사용할 것이 권장된다. 3GP는 3세대 휴대폰에 사용할 목적으로 Mepg4를 단순화한 포맷이라 대부분의 스마트폰과 호환된다. 물론 권장된다는 것이지 강제적인 것은 아니므로 장비의 성능이 따라 준다면 더 고품질 포맷을 사용해도 무방하다. 다음은 입력된 값을 압축할 인코더를 지정한다.

 

void setAudioEncoder (int audio_encoder)

void setVideoEncoder (int video_encoder)

 

오디오 인코더는 AMR_NB만 유효하며 비디오 인코더로 H263, H264, MPEG_4_SP 중 하나를 선택할 수 있다. 압축 방식에 따라 파일 크기와 품질이 달라질 것이다. 다음은 출력 파일의 경로를 지정한다. 음성이든 영상이든 결국은 파일 형태로 SD 카드에 저장되어야 하므로 녹음하기 전에 파일의 경로를 알려 주어야 한다.

 

void setOutputFile (String path)

 

서브 디렉토리에 저장할 경우 디렉토리는 반드시 미리 생성해 두어야 하며 파일은 당장 없어도 새로 생성된다. 입력과 출력, 인코딩 방식, 포맷 등을 순서대로 지정했는데 어디서 받은 음성을 어떤 방식으로 압축해서 어디다 저장할 것인지를 알려 준 것이다. 여기까지 진행한 후 다음 메서드로 녹음 및 녹화를 준비, 시작, 정지한다.

 

void prepare ()

void start ()

void stop ()

 

prepare를 호출하기 전에 녹음을 위한 모든 준비가 완료되어 있어야 하며 start를 호출하기 전에는 반드시 prepare를 먼저 호출해야 한다. 재생과는 달리 끝이 따로 없으므로 녹음은 stop 메서드를 호출할 때까지 계속된다. 특정 길이나 시간분만큼만 녹음을 하려면 prepare를 호출하기 전에 다음 메서드로 시간과 파일 크기의 상한선을 지정할 수 있다.

 

void setMaxDuration (int max_duration_ms)

void setMaxFileSize (long max_filesize_bytes)

 

특정 목적으로 사용할 파일이라면 길이나 시간을 제한할 필요가 있는데 예를 들어 MMS 첨부용 동영상은 시간 제한이 있다. 지정한 시간이나 용량에 이르면 OnInfoListener 콜백이 호출되며 녹음은 자동으로 정지된다. 다음 메서드는 다 사용한 후 객체를 리셋하거나 해제한다.

 

void release ()

void reset ()

 

2.2 버전 이후에는 비트 레이트나 샘플링 비율, 채널수 등을 상세 조정할 수 있는 메서드가 추가되었고 하드웨어로부터 프로필을 받아 옵션을 설정하는 기능도 추가되었다. 녹음을 하려면 다음 두 개의 퍼미션이 필요하다.

 

RECORD_AUDIO

WRITE_EXTERNAL_STORAGE

 

RECORD_AUDIO는 하고자 하는 작업과 이름이 일치하므로 직관적이다. 이 퍼미션 외에 SD 카드에 녹음된 파일을 생성해야 하므로 파일 기록 퍼미션도 필요하다. 너무나 당연하지만 레퍼런스에 명시되어 있지 않아 이 퍼미션을 빼 먹고 고생하는 사람들이 가끔 있는 것 같다. 바로 내가 그랬는데 이 퍼미션을 미처 생각치 못해 꼬박 하루동안 삽질을 했었던 아픈 추억이 있다.

녹음은 재생에 비해서 절차가 순차적이고 여러번 반복되지 않으므로 코드 작성이 훨씬 더 쉽다. 다음 예제는 간단한 오디오 녹음 기능과 제대로 녹음되었는지를 확인하는 기능을 제공한다. 녹음 버튼의 onClick 리스너에 작성된 코드가 녹음을 하는 절차의 정석이므로 이 코드를 그대로 따라하면 쉽게 녹음할 수 있다.

 

mm_RecAudio

public class mm_RecAudio extends Activity {

     MediaRecorder mRecorder = null;

     Button mStartBtn, mPlayBtn;

     boolean mIsStart = false;

     String Path = "";

    

     public void onCreate(Bundle savedInstanceState) {

          super.onCreate(savedInstanceState);

          setContentView(R.layout.mm_recaudio);

 

          mStartBtn = (Button)findViewById(R.id.start);

          mPlayBtn = (Button)findViewById(R.id.play);

         

          mStartBtn.setOnClickListener(new Button.OnClickListener() {

              public void onClick(View v) {

                   if (mIsStart == false) {

                      Path = "/sdcard/recaudio.3gp";

                        if (mRecorder == null) {

                             mRecorder = new MediaRecorder();

                        } else {

                             mRecorder.reset();

                        }

                        mRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);

                        mRecorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP);

                        mRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB);

                        mRecorder.setOutputFile(Path);

                        try {

                             mRecorder.prepare();

                        } catch (IllegalStateException e) {

                             Toast.makeText(mm_RecAudio.this, "IllegalStateException", 1).show();

                        } catch (IOException e) {

                             Toast.makeText(mm_RecAudio.this, "IOException", 1).show();

                        }

                        mRecorder.start();

                        mIsStart = true;

                        mStartBtn.setText("Stop");

                   } else {

                        mRecorder.stop();

                        mRecorder.release();

                        mRecorder = null;

                        mIsStart = false;

                        mStartBtn.setText("Start");

                   }

              }

          });

 

          mPlayBtn.setOnClickListener(new Button.OnClickListener() {

              public void onClick(View v) {

                   if (Path.length() == 0 || mIsStart) {

                        Toast.makeText(mm_RecAudio.this, "녹음을 먼저 하십시오.", 0).show();

                        return;

                   }

                   MediaPlayer player = new MediaPlayer();

                   try {

                        player.setDataSource(Path);

                        player.prepare();

                        player.start();

                   } catch (Exception e) {

                        Toast.makeText(mm_RecAudio.this, "error : " + e.getMessage(), 0).show();

                   }

              }

          });

     }

 

     public void onDestroy() {

        super.onDestroy();

        if (mRecorder != null) {

          mRecorder.release();

          mRecorder = null;

        }

    }

}

 

 

레이아웃에는 시작/정지 버튼과 녹음된 음성을 확인하는 버튼만 배치해 두었다. 위쪽 버튼 눌러 녹음을 시작하고 다시 한번 더 눌러 중지한 후 아래쪽 버튼 눌러보면 한 예제로 녹음과 결과 확인을 해 볼 수 있다. 편의상 녹음 파일의 위치와 이름은 고정해 두었다.


에뮬레이터는 마이크가 없고 DDMS에도 마이크를 흉내내는 기능은 제공되지 않는다. 레퍼런스에는 MediaRecord가 에뮬레이터에서는 동작하지 않는다고 명시되어 있지만 실제로 해 보면 호스트 PC의 마이크로 녹음 가능하다. 에뮬레이터의 기능은 개선되었는데 레퍼런스가 아직 업데이트되지 않은 것 같다. 실장비에서는 물론 아무 이상없이 잘 동작한다.




반응형