링드로이드 클론(영어학습기)에 MP4 동영상의 오디오를 시각화해서 보여주는 기능을 추가해보았다. TED 혹은 VOA의 동영상은 MP4 포맷이며, AAC 오디오가 들어있으므로 안드로이드의 미디어플레이어를 이용해도 별 문제 없이 재생되기때문에, 링드로이드 클론에서 오디오를 파형(waveform)으로 보여주기 기능을 추가하려면 ffmpeg 라이브러리인 libavcode을 사용하는 것이 가장 손쉬운 방식으로 생각하였다. ffmpeg을 안드로이드에서 사용하는 방법에 대한 자료가 꽤 많아서 비교적 손쉽게 이를 적용할 수 있었다.

우선 ffmpeg을 컴파일하는 방법을 검색해보면 그 방식도 꽤 복잡하여 겁이 나게 마련이다. ffmpeg을 컴파일하면 그 라이브러리 전체 크기만해도 10MB를 넘어간다. 라이브러리 크기도 크지만 라이브러리가 방대해서 다른 라이브러리간의 디펜던시 문제가 걸리면 복잡해지지 않을까 하고 지례 겁부터 먹었다. 안드로이드 펍에 수년전 올라온 방법부터 읽기도 전에 머리가 아프다.

그러나, CyanogenMod 트리를 살펴보니 이미 ffmpeg 라이브러리가 external 아래에 들어있는것이 아닌가!! 본인이 ffmpeg 적용을 주저하다가 당장에 코딩을 시작한 주된 요인중 하나이다.

(본인은 안드로이드 개발에서 흔한 이클립스 + 윈도우 빌드환경을 쓰지 않고, 리눅스 콘솔로 거의 대부분의 작업을 한다. 효율성은 떨어지지만 남들이 이미 개발해놓은 것들을 주워먹기 좋은 환경 ^^)

external/ffmpeg 디렉토리에 들어가서 mm명령으로 컴파일을 하니 몇십분 후에 libavutil / libavcodec / libavformat 등등의 ffmpeg 라이브러리들이 빌드가 되었다. (※여기서 주의할 점 한가지는, 빌드하기 전에 -U_FORTIFY_SOURCE 옵션을 CFLAGS에 반드시 넣어주어야 한다는 것이다. ffmpeg/libavutil 디렉토리에는 time.h 헤더가 들어있는데, 이상하게도 gcc의 -isystem 옵션이 말을 듣지 않아서 <time.h>를 인클루드 할 때에 엉뚱한 libavutil/time.h가 인클루드 된다. 이 문제를 회피하기 위해서 bionic/libc/include를 인클루드 경로로 추가하면 컴파일이 잘 되는데 이렇게 bionic의 헤더 경로를 추가하는 경우 GB/ICS와 호환되는 바이너리 파일을 얻기 위해서는 _FORTIFY_SOURCE를 define하지 말아야 한다는 것이다. 또한 추가적으로 GB/ICS에서 문제 없는 바이너리를 얻기 위해서는 LOCAL_SDK_VERSION := 9를 넣어주어야 한다. 이 경우 build/core/* 스크립트를 수정해서 INCLUDE_PATH에서 LOCAL_PATH를 제외시켜야 정상적으로 system의 <time.h>가 인크루드 된다. gcc는 컴파일하고 있는 현재 위치를 include path로 자동으로 지정하므로 $(LOCAL_PATH)를 LOCAL_C_INCLUDE 패스로 추가하고 있는 build/core/* 스크립트를 수정해야 하는 것)

이렇게 ffmpeg 라이브러리가 완성되었으니 가장 기본적인 작업이 완료되었다. 이제는 ffmpeg을 사용하여 오디오를 재생하는 예제를 찾아서 실행하고, 이를 링드로이드에 적용해봐야 할 차례이다. ffmpeg을 이용해서 오디오를 재생하는 방법 자체를 잘 모르므로 예제를 컴파일 및 실행해보면서 ffmpeg의 작동 방식을 이해해야 하는 것이다.

구글로 검색해보니 다음과 같은 자료를 stackoverflow에서 찾을 수 있었으며 이를 개선시킨 예제도 찾을 수 있었다. (아래 링크에 stackoverflow 관련 링크도 같이 있으니 참고하시기 바랍니다)

FFmpeg Audio Playback Sample (http://0xdeafc0de.wordpress.com/2013/12/19/ffmpeg-audio-playback-sample/)

이를 간단히 고쳐서 AO 라이브러리를 사용하여 재생하는 부분을 빼버리고 리눅스에서 컴파일하니 별 문제 없이 컴파일되고 실행되는 것을 확인하였다.

※ 변경된 실행 예제 : 

ffmpeg_audio_decode.cpp

혹은 C소스로 간단히 변경한것:

ffmpeg_audio_decode.c

예제를 보면서 ffmpeg이 어떤 식으로 작동하는지 간단히 알아보고자 한다.


/*
 * from http://0xdeafc0de.wordpress.com/2013/12/19/ffmpeg-audio-playback-sample/
 *
 * g++ -std=c++11 this_example.cpp `pkg-config --libs --cflags libavcodec libavformat libavutil`
 *
 * ./a.out foobar.mp4
 */
#include <iostream>
#include <limits>
#include <stdio.h>

extern "C" {
#include "libavutil/mathematics.h"
#include "libavutil/samplefmt.h"
#include "libavformat/avformat.h"
#include "libavformat/avio.h"
#include "libswscale/swscale.h"
//#include <ao/ao.h>
}

#define DBG(x) std::cout<<x<<std::endl
#define AVCODEC_MAX_AUDIO_FRAME_SIZE 192000

void die(const char *msg) {
    printf("%s\n",msg);
    exit(-1);
}

int main(int argc, char **argv) {
    const char* input_filename = argv[1];

    av_register_all();

    AVFormatContext* container = avformat_alloc_context();
    if (avformat_open_input(&container, input_filename, NULL, NULL) < 0) {
        die("Could not open file");
    }

    if (avformat_find_stream_info(container, NULL) < 0) {
        die("Could not find file info");
    }

    av_dump_format(container, 0, input_filename, false);
...

여기까지가 기본적인 ffmpeg을 사용하기 위한 초기화 과정이다. 마지막 줄 av_dump_format()은 파일의 기본적인 정보를 콘솔로 출력해준다.

그 다음에는 입력파일에 오디오 스트림 정보가 있는지 찾는 부분이다.

...
    int stream_id = -1;
    int i;
    for (i = 0; i < container->nb_streams; i++) {
        if (container->streams[i]->codec->codec_type == AVMEDIA_TYPE_AUDIO) {
            stream_id = i;
            break;
        }
    }
    if (stream_id == -1) {
        die("Could not find Audio Stream");
    }
...

AVMEDIA_TYPE_AUDIO 타입이 입력파일 스트림 (container->streams[])에 있는지 찾아보고, 있으면 stream_id에 그 값을 저장해둔다.

...
    AVCodecContext *ctx = container->streams[stream_id]->codec;
    AVCodec *codec = avcodec_find_decoder(ctx->codec_id);

    if (codec == NULL) {
        die("cannot find codec!");
    }

    if (avcodec_open2(ctx, codec, NULL) < 0) {
        die("Codec cannot be found");
    }
...

그 다음은 코덱 타입에 맞는 디코더를 찾아서 가져오고 디코딩을 시작하게 되는데 다음과 같이 디코딩 작업을 위한 몇가지 메모리 할당 및 초기화가 필요하다.

...
    AVPacket packet;
    av_init_packet(&packet);

    AVFrame *frame = avcodec_alloc_frame();

    int buffer_size = AVCODEC_MAX_AUDIO_FRAME_SIZE + FF_INPUT_BUFFER_PADDING_SIZE;

    // MSVC can't do variable size allocations on stack, ohgodwhy
    uint8_t *buffer = new uint8_t[buffer_size];
    packet.data = buffer;
    packet.size = buffer_size;

    uint8_t *samples = new uint8_t[buffer_size];
    int len;
    int frameFinished = 0;

    int plane_size;

    while (av_read_frame(container, &packet) >= 0) {
....

ffmpeg에서는 AVFrame 및 AVPacket을 할당하고 초기화를 하고 있고, 그 다음에 av_read_frame()을 사용하여 데이터를 packet/frame에 저장하고 있으며, frame/packet에 저장된 데이터가 오디오인 경우에 다음처럼 디코딩 과정을 거치고 frame->extended_data[]에 디코딩된 오디오 샘플이 저장되게 된다.

...
    while (av_read_frame(container, &packet) >= 0) {
        DBG((packet.pos)); // print out bytes offset

        if (packet.stream_index == stream_id) {
            int len = avcodec_decode_audio4(ctx, frame, &frameFinished, &packet);
            int data_size = av_samples_get_buffer_size(&plane_size,
                                ctx->channels,
                                frame->nb_samples,
                                ctx->sample_fmt, 1);
            uint16_t *out = (uint16_t *)samples;

avcodec_decode_audio4()가 성공을 하면 frameFinished에 0이상의 값이 설정이 된다. 이제 디코딩된 오디오 샘플을 적절히 변형하여서 안드로이드의 AudioTrack에 write()해주거나, 샘플을 읽어들여 파형을 출력하는 데이터로 사용할 수 있게 되었다. (이하 코드는 디코딩된 샘플의 포맷을 SHORT 포맷으로 바꿔주는 부분이다.)

by dumpcookie 2014. 11. 20. 14:56