이 Codelab에서는 오디오 샘플러를 빌드해 보겠습니다. 앱이 휴대전화에 내장되어 있는 마이크를 통해 오디오를 녹음하고 재생합니다.
녹음 버튼을 누르는 동안 앱이 오디오를 녹음하며 최대 녹음 길이는 10초입니다. 재생을 누르면 버튼을 누르고 있는 동안 녹음된 오디오가 한 번 재생됩니다. 또는 반복을 사용 설정하여 재생 버튼을 놓을 때까지 녹음 파일을 반복 재생할 수 있습니다. 녹음을 누를 때마다 이전 오디오 녹음 파일이 덮어써집니다.
학습할 내용
- 지연 시간이 짧은 녹음 스트림 만들기를 위한 기본 개념
- 마이크를 통해 녹음된 오디오 데이터 저장 및 재생 방법
기본 요건
이 Codelab을 시작하기 전에 WaveMaker 1부 Codelab을 완료하는 것이 좋습니다. WaveMaker 1부에서는 여기서 다루지 않은 오디오 스트림 만들기를 위한 몇 가지 기본 개념을 다룹니다.
필요한 항목
- Android 스튜디오 3.0.0 이상
- Android 8.0(API 수준 26) SDK
- NDK 및 빌드 도구 설치
- 테스트를 위해 Android 8.0(API 수준 26) 이상을 실행하는 시뮬레이터 또는 Android 기기
- C++에 관한 지식(필수는 아니지만 도움이 됨)
샘플러 앱은 4가지 구성요소로 이루어져 있습니다.
- UI: 자바로 작성되며, MainActivity 클래스는 터치 이벤트를 수신하고 JNI 브리지로 전달하는 역할을 합니다.
- JNI 브리지: 이 C++ 파일은 JNI를 사용하여 UI와 C++ 객체 간의 통신 메커니즘을 제공합니다. 이벤트를 UI에서 오디오 엔진으로 전달합니다.
- 오디오 엔진: 이 C++ 클래스는 녹음 및 재생 오디오 스트림을 만듭니다.
- 녹음: 이 C++ 클래스는 오디오 데이터를 메모리에 저장합니다.
아키텍처는 다음과 같습니다.
프로젝트 클론
GitHub에서 Codelab 저장소를 클론합니다.
git clone https://github.com/googlecodelabs/android-wavemaker2
Android 스튜디오로 프로젝트 가져오기
Android 스튜디오를 열고 프로젝트를 가져옵니다.
- File > New > New Project...로 이동합니다.
- 'android-wavemaker2' 폴더를 선택합니다.
프로젝트 실행
기본 실행 구성을 선택합니다.
그런 다음 Ctrl+R을 눌러 템플릿 앱을 빌드하고 실행합니다. 컴파일되어 실행되지만 기능은 없습니다. 이 Codelab에서 기능을 추가할 것입니다.
기본 모듈 열기
이 Codelab에서 작업할 파일은 base
모듈에 저장됩니다. Project 창에서 이 모듈을 확장하고 Android 뷰가 선택되었는지 확인합니다.
참고: 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입니다.
각 샘플을 쓰고 난 후 다음 쓰기 색인이 하나씩 이동합니다.
이는 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
와 유사합니다. 다음 읽기 색인을 저장하세요.
샘플 읽기가 완료된 후 읽기 색인을 증분합니다.
이 단계를 요청된 개수의 샘플을 읽을 때까지 반복합니다. 가용 데이터의 끝에 도달하면 어떻게 될까요? 두 가지 동작이 있습니다.
- 반복을 사용 설정함: 읽기 색인을 0으로 설정합니다(데이터 배열의 시작).
- 반복을 사용 중지함: 아무것도 하지 않으며 읽기 색인을 증분하지 않습니다.
두 가지 동작을 사용하려면 언제 가용 데이터의 끝에 도달하는지 알아야 합니다. 편리하게도 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
메서드를 사용하여audioData
를SoundRecording
에 제공합니다.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
를 모노에서 스테레오로 변환합니다.- 읽은 프레임 수가 요청된 프레임 수보다 적다면 녹음된 데이터의 끝에 도달한 것입니다.
mIsPlaying
을false
로 설정하여 재생을 중지합니다.
다음 코드를 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)
녹음 버튼을 누르면 isRecording
이 true가 됩니다. 버튼을 놓으면 isRecording
이 false가 됩니다.
구성 변수 mIsRecording
은 recordingCallback
내의 데이터 스토리지를 전환하는 데 사용됩니다. isRecording
의 값으로 설정하기만 하면 됩니다.
추가해야 하는 동작이 하나 더 있습니다. 녹음을 시작하면 mSoundRecording
의 기존 데이터를 덮어쓰게 됩니다. 이는 clear
사용에 따른 결과이며, 이로 인해 mSoundRecording
의 쓰기 색인이 0으로 재설정됩니다.
다음은 setRecording
의 코드입니다.
if (isRecording) mSoundRecording.clear();
mIsRecording = isRecording;
재생 시작 및 중지
재생 제어는 녹음과 유사합니다. 재생 버튼을 누르거나 놓으면 setPlaying
이 호출됩니다. 구성 변수 mIsPlaying
은 playbackCallback
내부에서 재생을 전환합니다.
재생 버튼을 눌렀을 때 녹음된 오디오 데이터가 처음부터 재생되도록 하려면 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가 표시됩니다.
녹음을 시작하려면 빨간색 버튼을 탭합니다. 버튼을 누르고 있는 동안 최대 10초까지 녹음이 계속됩니다. 녹색 버튼을 탭하여 녹음된 데이터를 재생합니다. 버튼을 누른 채로 오디오 데이터가 끝날 때까지 재생을 계속합니다. 반복이 사용 설정되면 오디오 데이터는 무한 반복됩니다.