05 NDS 홈브루(Homebrew) - MP3 Player 만들기

원문 :  http://kkamagui.springnote.com/pages/525438

 

들어가기 전에...

 

0.시작하면서...

 NDS를 사용하는 사람이라면 문쉘에 대해서 모르는 사람이 없을 것이다. MP3 Player는 물론 Text Viewer, Binary Viewer, 동영상 재생까지 모든 기능을 갖추고 있는 만능 쉘이다.  뭔지 모르는 사람들은... 끄응... 구글링이라도 한번... ㅡ_ㅡa...

 나도 간단한 쉘을 만들고 있기에, MP3 Player에 과감히 도전해 보았다. 

 

1.MP3 Decoder Library 선택 

 문쉘이 쓰고 있는 MP3 Decoder는 libmad로써 나름 괜찮은 성능을 자랑한다. 하지만 단점이라면 큰 크기랄까... ㅡ_ㅡ;;; 실제 libmad를 다운받아서 ARM7에 올렸는데, Huffman 테이블의 크기도 크고 코드 량이 많아서 NDS의 ARM7쪽 코드 및 데이터 영역을 넘어서 버렸다. 컴파일 다 하고 링크할 때 오류가 나서 빌드가 안되는 말도안되는 상황이... ㅡ_ㅡ;;;; 

 문쉘은 필요없는 부분을 추려내서 사이즈를 줄인 것 같은데, 자세하게 분석해 보지 않아서 정확하게는 모르겠다. 

 마구 추려내기 꺼림직하여 좀더 작은 크기의 library를 찾아보니 Helix library가 있었다. https://helixcommunity.org/ 에서 다운로드 받을 수 있는데, 절차가 아주 까다롭고 불편했다. 뭐 여러가지 검색을 하다보니 겨우 소스를 구할 수 있었는데, helix.zip 를 클릭하면 된다(첨부에도 넣어놨다).

 Helix Community에서도 볼 수 있지만 장점이라면 fixed point에 최적화 되었고 작은 크기와 적은 CPU 사용량이랄까... 진짜 그런지는 모르겠지만 일단 크기가 작다니 믿고 쓰기로 했다. 

 

2.Helix Decoder 컴파일 

 일단 소스를 ARM9 이나 ARM7에 부은 다음 makefile을 적절히 수정해야 한다. 일단 Helix 소스가 MyLibrary 아래에 있다고 가정하고 makefile을 수정하는 예이다.

  1. ... 생략 ... 
  2. SOURCES  := source MyLibrary MyLibrary/real MyLibrary/real/arm 
  3. INCLUDES := include build MyLibrary/pub
  4. ... 생략 ...
  5. ARCH := -mthumb-interwork  <== 반드시 -mthumb 을 제거해야 한다. -mthumb를 제거하지 않으면 library 빌드 시에 ARM 모드 명령을 처리할 수 없어서 에러가 발생한다.
  6. CFLAGS := -g -Wall -O1 -DARM \ <== 다양한 플랫폼을 제공하기 때문에 arm 용이라는 매크로를 정의해 준다.
        -march=armv5te -mtune=arm946e-s -fomit-frame-pointer\
       -ffast-math \
       $(ARCH)
    ... 생략 ... 

 위에서 보듯 -mthumb를 제거해야 한다. mthumb는 thumb 모드로 컴파일하라는 옵션인데, 이것을 지우지 않아서 빌드가 안되 한참 고생했다. 위의 예제는 ARM9 에서 빌드하여 사용한다고 가정했다.

 

3.Helix 사용 예제 

 아래 코드는 직접 파일에서 MP3 데이터를 읽어서 디코딩하는 과정을 나타낸 소스코드이다.

  1. #include "nds.h"

    #include <nds/arm9/console.h> //basic print funcionality

    #include <stdio.h>

    #include <fat.h>

    #include "mp3dec.h"

  2.  

    /**
        버퍼를 파일에서 읽어서 다시 체운다.
    */
    void RefillBuffer( FILE* fp, char* pcDst, int iDstSize, char* pcRemain,
        int iRemainSize )
    {
        if( pcRemain != NULL )
        {
            memcpy( pcDst, pcRemain, iRemainSize );
        }
        
        // 버퍼를 채우고 다시 찾는다.
        fread( pcDst + iRemainSize, iDstSize - iRemainSize, 1, fp );

  3.  

  4. //---------------------------------------------------------------------------------

    int main(void) {

    //---------------------------------------------------------------------------------

    touchPosition touchXY;

    HMP3Decoder hMp3;

    unsigned char* pcBuffer;

    FILE* fp;

    int iSync;

    int iRet;

    int iByteLeft;

    int iFrameIndex;

     

    videoSetMode(0); //not using the main screen

    videoSetModeSub(MODE_0_2D | DISPLAY_BG0_ACTIVE); //sub bg 0 will be used to print text

    vramSetBankC(VRAM_C_SUB_BG);

     

    SUB_BG0_CR = BG_MAP_BASE(31);

     

    BG_PALETTE_SUB[255] = RGB15(31,31,31); //by default font will be rendered with color 255

     

    //consoleInit() is a lot more flexible but this gets you up and running quick

    consoleInitDefault((u16*)SCREEN_BASE_BLOCK_SUB(31), (u16*)CHAR_BASE_BLOCK_SUB(0), 16);

     

    iMyPrintf("\n\n\tHello World!\n");

     

    // IRQ 설정

    irqInit();

    irqSet(IRQ_VBLANK, NULL);

    SetYtrigger(80);

    irqEnable(IRQ_VBLANK | IRQ_VCOUNT);

     

    // FAT를 초기화 한다.

    fatInitDefault();

     

    hMp3 = MP3InitDecoder();

    iMyPrintf( "init result = %X\n", hMp3 );

     

    fp = fopen( "/a.mp3", "r" );

    if( fp == NULL )

    {

    iMyPrintf( "File is null\n" );

    }

    else

    {

    iMyPrintf( "File is not null\n" );

    }

     

    // 버퍼를 체운다.

    RefillBuffer( fp, g_vcBuffer, sizeof( g_vcBuffer ), NULL, 0 );

     

    pcBuffer = g_vcBuffer;

    iByteLeft = sizeof( g_vcBuffer );

    iFrameIndex = 0;

    while( 1 )

    {

    iSync = MP3FindSyncWord( pcBuffer, iByteLeft );

    //iMyPrintf( "[%d] sync word = %d, Left = %d\n", iFrameIndex, iSync, iByteLeft );

    if( ( iSync < 0 ) || ( iByteLeft < 1024 ) )

    {

    //iMyPrintf( "[%d] Refill Buffer\n", iFrameIndex );

    RefillBuffer( fp, g_vcBuffer, sizeof( g_vcBuffer ), pcBuffer, iByteLeft );

     

    pcBuffer = g_vcBuffer;

    iByteLeft = sizeof( g_vcBuffer );

    iSync = 0;

    }

    iFrameIndex++;

     

    iByteLeft -= iSync;

    pcBuffer = pcBuffer + iSync;

     

    // 만약 다음 프레임이 유효하지 않으면 안을 체운다.

    if( MP3GetNextFrameInfo( hMp3, &g_stInfo, pcBuffer ) != 0 )

    {

    iMyPrintf( "[%d] Get Next Frame Info Error\n", iFrameIndex );

    while( 1 );

    }

     

  5.  

     // iRet가 0이 아니면 오류가 발생한 것이다.

     

    iRet = MP3Decode( hMp3, &pcBuffer, &iByteLeft, sOutBuffer, 0 );

    iMyPrintf( "[%d] Decode Result = %d, Left = %d\n", iFrameIndex, iRet,

    iByteLeft );

     

    MP3GetLastFrameInfo( hMp3, &g_stInfo );

    iMyPrintf( "%d %d %d %d %d %d\n", g_stInfo.bitrate, g_stInfo.nChans,

    g_stInfo.samprate, g_stInfo.outputSamps, g_stInfo.layer, g_stInfo.version );

    if( g_stInfo.outputSamps <= 0 )

    {

    break;

    }

     

    // 출력 셈플을 L/R로 바꾼후 Play 한다.

    SplitAndPlay( sOutBuffer, g_stInfo.outputSamps );

    }

     

    while(1) {

     

    touchXY=touchReadXY();

    //iMyPrintf("\x1b[20;0HTouch x = %04X, %04X\n", touchXY.x, touchXY.px);

    //iMyPrintf("Touch y = %04X, %04X\n", touchXY.y, touchXY.py);

    }

    return 0;

    }

 

4.실제 적용 

 Sound 출력 부분은 ARM7 만이 접근할 수 있다. 따라서 ARM9으로 디코딩한 후에 ARM7쪽에 동기를 맞추어 버퍼를 넘겨주던지, 아니면 ARM9에서 파일을 읽어서 ARM7에 넘겨주고 ARM7이 디코딩하고 결과를 출력하던지... 2가지 방법이 있다. 

 ARM7에서 파일을 읽고 디코딩하고 출력하는 방법은 안될까? 안타깝지만 테스트 결과 libfat가 ARM7에서 동작하지 않았다. 고로 우리의 선택은 두가지 중에 하나를 해야 하는데, 현재(2007/10/13 21:24:43) 테스트 프로그램은 ARM9에서 읽어서 약 10초간 디코딩한 후에 디코딩 버퍼를 ARM7에 넘겨주고 ARM7은 마치 자기가 디코딩해서 출력하는 양 버퍼를 잘라서 Timer에 맞추어 더블 버퍼링 비슷(?)하게 동작한다.

 즉 목표는 ARM9에서는 파일만 읽어서 데이터를 ARM7에 넘겨주고 ARM7이 디코딩 + 출력까지 다하는 것이다. 

 

 ARM9 이던 ARM7 이던 소리를 제대로 출력하기 위해서는 MP3 디코더에 의해서 디코딩된 프레임이 나왔을때 이를 적절한 타이밍에 잘 출력해 줘야 한다. 즉 타이밍이라는 문제에 봉착하는 것이다. 실제 Helix로 MP3 파일을 디코딩하면 하나의 프레임당 2304 개의 sample이 나온다. 스테레오라고 가정할 때 1152개의 L/R Sample이 나오는데, 44100Hz로 Sampling된 경우 겨우 몇 ms 정도 출력하는 양이다.

 우리가 제대로 된 소리를 들을려면 이 프레임을 계속 디코딩하여 sample을 얻고 sample을 정확한 시간에 출력해 주는 것이 관건인데, 이 문제로 거의 3일을 고민했다. 수많은 테스트를 거쳐서(삽질도 포함해서... ㅡ_ㅜ... 사운드 출력함수를 잘못쓰다니... 젠장... ㅜ_ㅜ...) 겨우 제대로된 소리를 출력할 수 있었다.

 중요한 테크닉은 타이머 및 인터럽트를 사용해서 sample이 Play 완료 되는 시점을 정확하게 구하고 이 시점에서 다음 버퍼를 다른 채널로 Play 시키는 것이다. 같은 채널로 Play 시키면 소리가 정지됬다가 나오므로 약간 잡음이 들린다.

 

 이 타이밍 문제에 대한 자세한 내용은 아래의 6.테스트 및 진행과정 을 참고하도록 하자.

 첨부에 포함된 테스트 프로그램은 Root Directory에 있는 a.mp3 파일을 읽어서 10초 분량을 디코딩한 뒤에 ARM7에 넘겨서 Play한 예제이다. 아직 갈길이 멀지만 기념으로 올린다.

5.첨부 

 

 

6.테스트 및 진행과정 

6.1 테스트 

  • Helix를 포팅하여 MP3를 디코딩하는데 성공
  • ARM9에서 돌렸을 때 디코딩되는 속도가 한 44100Hz로 인코딩된 프레임이 사운드로 출력되는 시간보다 빠른 것을 확인

    • 디코딩 후 바로 Play 했을 때 소리가 겹치고 귀로 들었을 때 프레임이 겹쳐서 소리가 1.5배속 정도로 빨리 플레이 됨
  • 하드에 있는 MP3 파일을 디코딩 했을 때 417Byte 정도당 2304 Sample이 나오는 것을 발견.

    • 2 Channel이면 LRLRLR과 같이 디코딩 되어있으므로 실제로 L과 R에 출력해야 하는 Sample의 수는 1152 Sample이 됨 (16bit) 
    • NDS의 사운드 출력시 4Byte로 Align 되어야 하는데 잘리는 Sample의 수 없이 잘 잘림 

MP3_디코딩중.PNG

<디코딩 정보>

  • ARM9에서 디코딩 후 SoundPlay() 함수로 ARM7에 넘겼을 때, 각종 지연 때문에 음이 끊어지고 정상적으로 Play되지 않음

    • ARM9에서 10초 정도 버퍼를 쌓은 다음 ARM7에 넘기고 ARM7에서는 디코딩된 크기별로 버퍼를 잘라서 더블 버퍼를 이용해 Play하도록 테스트를 해봐야 겠음
    • 위와 같이 테스트 하여 거의 노이즈가 없으면 ARM7으로 디코더를 옮겨서 다시 테스트함 
    • 파일 읽기는 ARM9에서만 가능하므로 ARM9에서 버퍼를 읽어서 넣어줘야 겠음 

 

6.2.Timing 문제 

2007/10/13 20:18:13 해결 

  • 두개의 Timer를 사용하여 Timing 문제를 해결 

    • Timer 0는 원래 Sound의 Sampling Rate로 맞춤 
    • Timer 1은 Cascade 모드로 설정하여 Timer 0가 Overflow 될때 마다(Sampling Rate 만큼 Overflow가 발생) 카운트 되도록 설정 
    • Timer 1의 Overflow는 Frame에 포함된 Sample 수만큼 지나면 발생하도록 하여 Overflow 발생 시 버퍼를 교체해 주는 방식으로 처리 
    • 타이머는 16 bit 크기이고 16bit의 값을 넘어서면 Overflow가 되면서 인터럽트가 발생하는 구조임 
  • 나름대로 소리 깨끗하게 나옴 
  • 아래는 타이밍 설정의 핵심부분 

    1. /**
          Sample Count와 Frequency로 Timer 주기를 설정한다.
      */
      void SetTimer( int iFrequency, int iSampleCount )
      {
          // Sound에 대한 설정
          SCHANNEL_TIMER( 0 )  = SOUND_FREQ( iFrequency );
          SCHANNEL_LENGTH( 0 ) = iSampleCount >> 1 ;
          SCHANNEL_REPEAT_POINT(0) = 0;
          SCHANNEL_TIMER( 1 )  = SOUND_FREQ( iFrequency );
          SCHANNEL_LENGTH( 1 ) = iSampleCount >> 1 ;
          SCHANNEL_REPEAT_POINT( 1 ) = 0;
    2.     // Sound Frequency와 동일하게 Timer를 설정하고 Timer 1을 Cascade로 맞춰서
          // 사운드의 동일한 hz에 맞춰서 동작하도록 한다.
          TIMER0_CR = 0;
          TIMER0_DATA = SOUND_FREQ( iFrequency );
          TIMER0_CR = TIMER_DIV_1 | TIMER_ENABLE;
    3.     // 여기서 2를 곱하는 이유는 Sound_Freq는 Timer_Freq의 2배 속도기 때문이다.
          // 자세한건 메크로를 확인하자.
          TIMER1_DATA = 0x10000 - ( iSampleCount * 2 );
          TIMER1_CR = TIMER_CASCADE | TIMER_IRQ_REQ | TIMER_ENABLE;
    4.     irqSet( IRQ_TIMER1, isrTimer1 );
          irqEnable( IRQ_TIMER1 );
  • 아래는 Timer Interrupt의 Buffer Switching 부분이다. 0 번 채널과 1번 채널을 번갈아 가면서 사용하여 소리를 출력하고 있음을 알 수 있다.

    1. short* psSoundBuffer;
      int iChannel = 0;
      short sOutputBuffer[ 1152 ];
    2. void isrTimer1( void )
      {
          startSound( 44100, psSoundBuffer, 1152 * 2, iChannel, 127, 0, 2 );
          iTimer = 0;
          iChannel = 1 - iChannel;
          psSoundBuffer += 1152;
  • 아래는 Sound 출력 함수이다. 원래 함수에서 변하지 않는 부분은 그대로 놔두도록 변경했다.

    1. //---------------------------------------------------------------------------------
      void startSound(int sampleRate, const void* data, u32 bytes, u8 channel, u8 vol,  u8 pan, u8 format) {
      //---------------------------------------------------------------------------------
       //SCHANNEL_TIMER(channel)  = SOUND_FREQ(sampleRate);
       SCHANNEL_CR(channel) = 0;
       SCHANNEL_SOURCE(channel) = (u32)data;
       //SCHANNEL_LENGTH(channel) = bytes >> 2 ;
       //SCHANNEL_REPEAT_POINT(channel) = 0;
       SCHANNEL_CR(channel) = SCHANNEL_ENABLE | SOUND_ONE_SHOT | SOUND_VOL(vol) | SOUND_PAN(pan) | (format==1?SOUND_8BIT:SOUND_16BIT);

 

6.3 2007/10/13 20:18:55 이전 테스트... 

  • 정확하게 Sample이 Play되는 시간을 찾아서 그 시간에 다시 Sound FIFO에 데이터를 넣어줘야 함. 그렇지 못할 경우 White Noise가 생김.
  • ARM7의 Channel 설정 부분도 손볼 필요가 있음. 
  • Contol Register의 Hold Flag를 사용하면 한 Sample 정도 소리를 유지할 수 있음(자세한건 스펙 참조)

  • 그냥 사운드 컨트롤을 사용해서 넣을 경우 레지스터에 설정하는 크기는 Word 단위(4Byte) 이므로 혹시나 짝이 안맞아서 절삭되는 Sample 때문에 White Noise가 생기는 것은 아닌가 의문 
  • DMA를 사용해서 버퍼를 넣는 방법을 생각해 보기

    • NDS에서는 Sound가 자체 DMA를 통해 날아감 
    • Sound Control 부분만 잘 설정하면 됨

 

7.마치면서... 

 간단하게나마 NDS에 MP3 Player를 포팅(??)하는 방법에 대해서 알아보았다. 아직 갈길이 멀지만 MP3 Decoder를 포팅했다는 것만으로도 의의가 있다고 생각한다. 이제 좀더 발전시켜서 완전한 MP3 Player를 만들어 보자.

 

8.TODO 

  • ARM9에서 ARM7으로 Helix 코드 이동하기
  • ARM9에서 파일을 밀어주고 ARM7에서 디코딩 가능하도록 소스 수정하기 
  • 내 쉘에 올리기 

 

 

이 글은 스프링노트에서 작성되었습니다.

+ Recent posts