웨이브 더 많이 만들기: 샘플러

이 Codelab에서는 오디오 샘플러를 빌드해 보겠습니다. 앱이 휴대전화에 내장되어 있는 마이크를 통해 오디오를 녹음하고 재생합니다.

녹음 버튼을 누르는 동안 앱이 오디오를 녹음하며 최대 녹음 길이는 10초입니다. 재생을 누르면 버튼을 누르고 있는 동안 녹음된 오디오가 한 번 재생됩니다. 또는 반복을 사용 설정하여 재생 버튼을 놓을 때까지 녹음 파일을 반복 재생할 수 있습니다. 녹음을 누를 때마다 이전 오디오 녹음 파일이 덮어써집니다.

7eb653b71774dfed.png

학습할 내용

  • 지연 시간이 짧은 녹음 스트림 만들기를 위한 기본 개념
  • 마이크를 통해 녹음된 오디오 데이터 저장 및 재생 방법

기본 요건

이 Codelab을 시작하기 전에 WaveMaker 1부 Codelab을 완료하는 것이 좋습니다. WaveMaker 1부에서는 여기서 다루지 않은 오디오 스트림 만들기를 위한 몇 가지 기본 개념을 다룹니다.

필요한 항목

샘플러 앱은 4가지 구성요소로 이루어져 있습니다.

  • UI: 자바로 작성되며, MainActivity 클래스는 터치 이벤트를 수신하고 JNI 브리지로 전달하는 역할을 합니다.
  • JNI 브리지: 이 C++ 파일은 JNI를 사용하여 UI와 C++ 객체 간의 통신 메커니즘을 제공합니다. 이벤트를 UI에서 오디오 엔진으로 전달합니다.
  • 오디오 엔진: 이 C++ 클래스는 녹음 및 재생 오디오 스트림을 만듭니다.
  • 녹음: 이 C++ 클래스는 오디오 데이터를 메모리에 저장합니다.

아키텍처는 다음과 같습니다.

a37150c7e35aa3f8.png

프로젝트 클론

GitHub에서 Codelab 저장소를 클론합니다.

git clone https://github.com/googlecodelabs/android-wavemaker2

Android 스튜디오로 프로젝트 가져오기

Android 스튜디오를 열고 프로젝트를 가져옵니다.

  • File > New > New Project...로 이동합니다.
  • 'android-wavemaker2' 폴더를 선택합니다.

프로젝트 실행

기본 실행 구성을 선택합니다.

f65428e71e9bdbcf.png

그런 다음 Ctrl+R을 눌러 템플릿 앱을 빌드하고 실행합니다. 컴파일되어 실행되지만 기능은 없습니다. 이 Codelab에서 기능을 추가할 것입니다.

기본 모듈 열기

이 Codelab에서 작업할 파일은 base 모듈에 저장됩니다. Project 창에서 이 모듈을 확장하고 Android 뷰가 선택되었는지 확인합니다.

cae7ee7b54407790.png

참고: WaveMaker2 앱의 완성된 소스 코드는 final 모듈에서 찾을 수 있습니다.

SoundRecording 객체는 메모리에 있는 녹음된 오디오 데이터를 나타냅니다. 앱이 마이크의 데이터를 메모리에 쓰고 재생을 위해 데이터를 읽도록 허용합니다.

먼저 이 오디오 데이터를 저장하는 방법을 살펴보겠습니다.

오디오 형식 선택

먼저 샘플의 오디오 형식을 선택해야 합니다. AAudio는 두 가지 형식을 지원합니다.

  • float: 단일 정밀도 부동 소수점(샘플당 4바이트)
  • int16_t: 16비트 정수(샘플당 2바이트)

낮은 볼륨에서 음질이 우수한 점 및 기타 이유로 인해 float 샘플을 사용합니다. 메모리 용량이 문제인 경우 16비트 정수를 사용하면 음질을 낮추고 용량을 확보할 수 있습니다.

필요한 저장용량 선택

10초짜리 오디오 데이터를 저장한다고 가정해 봅시다. 최신 Android 기기에서는 초당 샘플 48,000개의 샘플링 레이트가 가장 일반적이므로 샘플 480,000개에 메모리를 할당해야 합니다.

base/cpp/SoundRecording.h를 열고 파일 상단에 이 상수를 정의합니다.

constexpr int kMaxSamples = 480000; // 10s of audio data @ 48kHz

저장용량 배열 정의

이제 float의 배열을 정의하는 데 필요한 모든 정보를 확보했습니다. SoundRecording.h에 다음 선언을 추가합니다.

private:
    std::array<float,kMaxSamples> mData { 0 };

{ 0 }집계 초기화를 사용하여 배열의 모든 값을 0으로 설정합니다.

write 구현

write 메서드에는 다음 서명이 있습니다.

int32_t write(const float *sourceData, int32_t numFrames);

이 메서드는 sourceData에서 오디오 샘플 배열을 수신합니다. 배열의 크기는 numFrames로 지정됩니다. 이 메서드는 실제로 쓰는 샘플 수를 반환해야 합니다.

이는 사용 가능한 다음 쓰기 색인을 저장하여 구현할 수 있습니다. 처음에는 0입니다.

9b3262779d7a0a8c.png

각 샘플을 쓰고 난 후 다음 쓰기 색인이 하나씩 이동합니다.

2971acee93b9869d.png

이는 for 루프로 쉽게 구현할 수 있습니다. SoundRecording.cpp의 write 메서드에 다음 코드를 추가합니다.

for (int i = 0; i < numSamples; ++i) {
    mData[mWriteIndex++] = sourceData[i];
}
return numSamples;

하지만 남은 공간보다 용량이 큰 샘플을 쓰려고 한다면 어떻게 될까요? 문제가 발생할 것입니다. 경계 밖에 있는 배열 색인에 액세스하려고 하므로 세분화 오류가 발생합니다.

mData에 남은 공간이 부족한 경우 numSamples를 변경하는 코드를 추가합니다. 기존 코드 에 다음을 추가합니다.

if (mWriteIndex + numSamples > kMaxSamples) {
    numSamples = kMaxSamples - mWriteIndex;
}

read 구현

read 메서드는 write와 유사합니다. 다음 읽기 색인을 저장하세요.

488ab2652d0d281d.png

샘플 읽기가 완료된 후 읽기 색인을 증분합니다.

1a7fd22f4bbb4940.png

이 단계를 요청된 개수의 샘플을 읽을 때까지 반복합니다. 가용 데이터의 끝에 도달하면 어떻게 될까요? 두 가지 동작이 있습니다.

  • 반복을 사용 설정함: 읽기 색인을 0으로 설정합니다(데이터 배열의 시작).
  • 반복을 사용 중지함: 아무것도 하지 않으며 읽기 색인을 증분하지 않습니다.

789c2ce74c3a839d.png

두 가지 동작을 사용하려면 언제 가용 데이터의 끝에 도달하는지 알아야 합니다. 편리하게도 mWriteIndex에서 이를 알려줍니다. 여기에는 데이터 배열에 작성된 샘플의 총 개수가 포함됩니다.

이 점을 고려하여 이제 SoundRecording.cpp에서 read 메서드를 구현할 수 있습니다.

int32_t framesRead = 0;
while (framesRead < numSamples && mReadIndex < mWriteIndex){
    targetData[framesRead++] = mData[mReadIndex++];
    if (mIsLooping && mReadIndex == mWriteIndex) mReadIndex = 0;
}
return framesRead;

AudioEngine은 다음과 같은 주요 작업을 합니다.

  • SoundRecording 인스턴스 만들기
  • 마이크를 통해 데이터를 녹음하는 녹음 스트림 만들기
  • 녹음 스트림 콜백에서 녹음된 데이터를 SoundRecording 인스턴스에 작성
  • 녹음된 데이터를 재생하기 위해 재생 스트림 만들기
  • 재생 스트림 콜백에서 SoundRecording 인스턴스에 있는 녹음된 데이터 읽기
  • 녹음, 재생, 반복을 위한 UI 이벤트에 응답

먼저 SoundRecording 인스턴스를 만들어 보겠습니다.

쉬운 내용으로 시작해 보겠습니다. AudioEngine.h에서 SoundRecording 인스턴스를 만듭니다.

private:
    SoundRecording mSoundRecording;

두 가지 스트림인 재생과 녹음이 있습니다. 어느 스트림을 먼저 만들어야 할까요?

가장 짧은 지연 시간을 제공하는 샘플링 레이트가 하나만 있으므로 재생 스트림을 먼저 만들어야 합니다. 재생 스트림을 만들면 녹음 스트림 빌더에 샘플링 레이트를 제공할 수 있습니다. 이렇게 하면 재생 및 녹음 스트림의 샘플링 레이트가 같습니다. 즉, 추가 작업 없이 스트림 간에 리샘플링할 수 있습니다.

재생 스트림 속성

다음 속성을 사용하여 재생 스트림을 만드는 StreamBuilder를 채웁니다.

  • 방향: 지정되지 않음, 기본값은 출력
  • 샘플링 레이트: 지정되지 않음, 가장 짧은 지연 시간이 기본값
  • 형식: 부동 소수점 수
  • 채널 수: 2개(스테레오)
  • 성능 모드: 짧은 지연 시간
  • 공유 모드: 전용

재생 스트링 만들기

이제 재생 스트림을 만들고 열기 위해 필요한 모든 것이 준비되었습니다. AudioEngine.cpp의 start 메서드 상단에 다음 코드를 추가합니다.

// Create the playback stream.
StreamBuilder playbackBuilder = makeStreamBuilder();
AAudioStreamBuilder_setFormat(playbackBuilder.get(), AAUDIO_FORMAT_PCM_FLOAT);
AAudioStreamBuilder_setChannelCount(playbackBuilder.get(), kChannelCountStereo);
AAudioStreamBuilder_setPerformanceMode(playbackBuilder.get(), AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
AAudioStreamBuilder_setSharingMode(playbackBuilder.get(), AAUDIO_SHARING_MODE_EXCLUSIVE);
AAudioStreamBuilder_setDataCallback(playbackBuilder.get(), ::playbackDataCallback, this);
AAudioStreamBuilder_setErrorCallback(playbackBuilder.get(), ::errorCallback, this);

aaudio_result_t result = AAudioStreamBuilder_openStream(playbackBuilder.get(), &mPlaybackStream);

if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error opening playback stream %s",
                       AAudio_convertResultToText(result));
   return;
}

// Obtain the sample rate from the playback stream so we can request the same sample rate from
// the recording stream.
int32_t sampleRate = AAudioStream_getSampleRate(mPlaybackStream);

result = AAudioStream_requestStart(mPlaybackStream);
if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error starting playback stream %s",
                       AAudio_convertResultToText(result));
   closeStream(&mPlaybackStream);
   return;
}

데이터 및 오류 콜백 관련 템플릿 메서드가 만들어졌습니다. 작동 방식을 다시 살펴보려면 첫 번째 Codelab을 참고하세요.

녹음 스트림 속성

녹음 스트림을 만들려면 다음 속성을 사용합니다.

  • 방향: 입력(녹음은 입력, 재생은 출력임)
  • 샘플링 레이트: 출력 스트림과 동일
  • 형식: 부동 소수점 수
  • 채널 수: 1개(모노)
  • 성능 모드: 짧은 지연 시간
  • 공유 모드: 전용

녹음 스트림 만들기

이제 이전에 start에 추가한 코드 아래에 다음 코드를 추가합니다.

// Create the recording stream.
StreamBuilder recordingBuilder = makeStreamBuilder();
AAudioStreamBuilder_setDirection(recordingBuilder.get(), AAUDIO_DIRECTION_INPUT);
AAudioStreamBuilder_setPerformanceMode(recordingBuilder.get(), AAUDIO_PERFORMANCE_MODE_LOW_LATENCY);
AAudioStreamBuilder_setSharingMode(recordingBuilder.get(), AAUDIO_SHARING_MODE_EXCLUSIVE);
AAudioStreamBuilder_setFormat(recordingBuilder.get(), AAUDIO_FORMAT_PCM_FLOAT);
AAudioStreamBuilder_setSampleRate(recordingBuilder.get(), sampleRate);
AAudioStreamBuilder_setChannelCount(recordingBuilder.get(), kChannelCountMono);
AAudioStreamBuilder_setDataCallback(recordingBuilder.get(), ::recordingDataCallback, this);
AAudioStreamBuilder_setErrorCallback(recordingBuilder.get(), ::errorCallback, this);

result = AAudioStreamBuilder_openStream(recordingBuilder.get(), &mRecordingStream);

if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error opening recording stream %s",
                       AAudio_convertResultToText(result));
   closeStream(&mRecordingStream);
   return;
}

result = AAudioStream_requestStart(mRecordingStream);
if (result != AAUDIO_OK){
   __android_log_print(ANDROID_LOG_DEBUG, __func__,
                       "Error starting recording stream %s",
                       AAudio_convertResultToText(result));
   return;
}

이제 마이크를 통해 녹음된 데이터를 SoundRecording 객체에 저장하는 재미있는 작업을 시작하겠습니다.

녹음 스트림을 빌드할 때 데이터 콜백을 ::recordingDataCallback으로 지정했습니다. 이 메서드는 다음 서명이 있는 AudioEngine::recordingCallback을 호출합니다.

aaudio_data_callback_result_t AudioEngine::recordingCallback(float *audioData,
                                                            int32_t numFrames)

오디오 데이터는 audioData.에서 제공됩니다. 모노로 녹음하여 프레임당 하나의 샘플만 있으므로 오디오 데이터의 크기(샘플 형식)는 numFrames입니다.

다음 작업을 하면 됩니다.

  • mIsRecording을 보고 녹음이 진행되고 있는지 확인합니다.
  • 그렇지 않은 경우 입력 데이터를 무시합니다.
  • 녹음 중이면 다음과 같이 합니다.
  • write 메서드를 사용하여 audioDataSoundRecording에 제공합니다.
  • write의 반환 값을 확인합니다. 값이 0인 경우 SoundRecording이 가득 차서 녹음을 중지해야 함을 의미합니다.
  • 콜백이 계속되도록 AAUDIO_CALLBACK_RESULT_CONTINUE를 반환합니다.

다음 코드를 recordingCallback에 추가합니다.

if (mIsRecording) {
    int32_t framesWritten = mSoundRecording.write(audioData, numFrames);
    if (framesWritten == 0) mIsRecording = false;
}
return AAUDIO_CALLBACK_RESULT_CONTINUE;

녹음 스트림과 마찬가지로 새 데이터가 필요한 경우 재생 스트림이 playbackDataCallback을 호출합니다. 이 메서드는 다음 서명이 있는 AudioEngine::playbackCallback,을 호출합니다.

aaudio_data_callback_result_t AudioEngine::playbackCallback(float *audioData, int32_t numFrames)

이 메서드 내에서 다음 작업을 해야 합니다.

  • fillArrayWithZeros를 사용하여 배열을 0으로 채웁니다.
  • mIsPlaying으로 표시된 녹음 데이터를 재생하고 있다면 다음과 같이 합니다.
  • mSoundRecording의 데이터 numFrames를 읽습니다.
  • convertArrayMonoToStereo를 사용하여 audioData를 모노에서 스테레오로 변환합니다.
  • 읽은 프레임 수가 요청된 프레임 수보다 적다면 녹음된 데이터의 끝에 도달한 것입니다. mIsPlayingfalse로 설정하여 재생을 중지합니다.

다음 코드를 playbackCallback에 추가합니다.

fillArrayWithZeros(audioData, numFrames * kChannelCountStereo);

if (mIsPlaying) {
   int32_t framesRead = mSoundRecording.read(audioData, numFrames);
   convertArrayMonoToStereo(audioData, framesRead);
   if (framesRead < numFrames) mIsPlaying = false;
}
return AAUDIO_CALLBACK_RESULT_CONTINUE;

녹음 시작 및 중지

setRecording 메서드는 녹음을 시작하고 중지하는 데 사용됩니다. 서명은 다음과 같습니다.

void setRecording(bool isRecording)

녹음 버튼을 누르면 isRecordingtrue가 됩니다. 버튼을 놓으면 isRecordingfalse가 됩니다.

구성 변수 mIsRecordingrecordingCallback 내의 데이터 스토리지를 전환하는 데 사용됩니다. isRecording의 값으로 설정하기만 하면 됩니다.

추가해야 하는 동작이 하나 더 있습니다. 녹음을 시작하면 mSoundRecording의 기존 데이터를 덮어쓰게 됩니다. 이는 clear 사용에 따른 결과이며, 이로 인해 mSoundRecording의 쓰기 색인이 0으로 재설정됩니다.

다음은 setRecording의 코드입니다.

if (isRecording) mSoundRecording.clear();
mIsRecording = isRecording;

재생 시작 및 중지

재생 제어는 녹음과 유사합니다. 재생 버튼을 누르거나 놓으면 setPlaying이 호출됩니다. 구성 변수 mIsPlayingplaybackCallback 내부에서 재생을 전환합니다.

재생 버튼을 눌렀을 때 녹음된 오디오 데이터가 처음부터 재생되도록 하려면 mSoundRecording의 읽기 헤드를 0으로 재설정하는 setReadPositionToStart를 사용하면 됩니다.

다음은 setPlaying의 코드입니다.

if (isPlaying) mSoundRecording.setReadPositionToStart();
mIsPlaying = isPlaying;

반복 재생 전환

마지막으로 반복 스위치를 전환하면 setLooping이 호출됩니다. 이를 간단히 처리할 수 있습니다. isOn 인수를 mSoundRecording.setLooping으로 전달하면 됩니다.

다음은 setLooping의 코드입니다.

mSoundRecording.setLooping(isOn);

오디오를 녹음하는 앱은 사용자에게 RECORD_AUDIO 권한을 요청해야 합니다. 권한 처리 코드 중 상당수가 이미 작성되어 있지만, 그래도 앱이 이 권한을 사용한다고 선언해야 합니다.

다음 행을 <manifest> 섹션 내의 manifests/AndroidManifest.xml에 추가합니다.

<uses-permission android:name="android.permission.RECORD_AUDIO" />

그동안 고생한 보람이 있는지 확인해 보겠습니다. 앱을 빌드하고 실행하면 다음 UI가 표시됩니다.

7eb653b71774dfed.png

녹음을 시작하려면 빨간색 버튼을 탭합니다. 버튼을 누르고 있는 동안 최대 10초까지 녹음이 계속됩니다. 녹색 버튼을 탭하여 녹음된 데이터를 재생합니다. 버튼을 누른 채로 오디오 데이터가 끝날 때까지 재생을 계속합니다. 반복이 사용 설정되면 오디오 데이터는 무한 반복됩니다.

추가 자료

고성능 오디오 샘플

Android NDK 문서의 고성능 오디오 가이드

Android 오디오 동영상 권장사항: Google I/O 2017