MenuIcon

Owl-Networks Archive

LoginIcon

MML 의 MIDI 변환, 사운드폰트 적용 및 웹 서비스

| 분류: Perl | 최초 작성: 2013-11-18 03:07:11 |

1. 이 문서에 대하여

  • 이 문서는 MML(Music Markup Language 또는 Music Macro Language)로 작성된 코드를 변환 과정을 통하여 사운드폰트를 적용한 MP3 등 음원 파일로 변환하는 방법론을 기술한 문서입니다.
  • 이 문서는 리눅스 서버 환경에서 웹 서비스를 통해 MML 코드를 MP3 파일로 변환하여 서비스하는 것을 목표로 합니다. 일반 사용자의 경우에는 이 문서를 읽으실 필요가 없습니다.
  • 만약 당신이 윈도우 사용자이고, 위와 같은 기능을 하는 프로그램을 찾고 있다면, 이 프로그램을 사용하십시오. 이 게시물의 내용을 실제 프로그램으로 구현한 것입니다.
  • 이 문서를 이해하기 위해서는 최소한 C 소스를 일반 환경에서 컴파일하고 적용할 수 있을 정도의 지식이 필요하며 (혹시라도 컴파일 오류가 발생할 경우에는 문제의 해결을 위해 구체적인 C 언어 지식 또는 리눅스 서버 관련 지식이 필요하게 될 수도 있습니다), 일부 스크립트의 구현 언어가 Perl 이므로 최소한 Perl 코드를 읽을 수 있어야 합니다.
  • Perl 언어의 특성 및 방법론적 특성상, 이 방법론은 웹과 로컬, 리눅스와 윈도우 각각의 환경 모두에 특별한 수정 없이 적용할 수 있습니다. (단, 웹에서 적용 시 CGI 형태로 동작시켜야 할 수도 있습니다.)

2. 방법론의 개관

MML을 직접 사운드폰트를 적용한 음원 파일로 생성하는 간단한 방법은 존재하지 않습니다. 따라서, MML 을 일단 MIDI 로 컨버팅한 후, 이를 사운드폰트를 적용한 음원 파일로 변환하는 방법론을 기술할 것입니다. 이 장의 내용은 개괄적인 방법론을 서술하며, 구체적인 문제에 대해서는 각각 별도의 장을 할애하여 서술합니다.

(1) MML 을 MIDI 로 변환

Perl 의 MIDI 모듈을 이용하면 MML 코드를 약간의 변환을 거쳐 MIDI 파일로 변환할 수 있습니다. 기초 수준의 Perl 코딩을 할 수 있어야 합니다. (이 문서에서 예제 코드를 완성해 볼 것입니다.)

(2) MIDI 파일에 사운드폰트를 적용하기

미디 파일은 악기 음원의 정보를 갖고 있지 않으므로, 특정한 악기 소리를 내게 하려면 사운드폰트 파일이 필요합니다. 따라서, 우선 사운드폰트 파일(*.sf2)을 직접 생성하거나 사운드폰트 파일을 구해야 합니다.

또한, MIDI 파일에 사운드폰트 파일을 적용하기 위해서는 TiMidity++ 라는 프로그램이 필요합니다. 이 프로그램은 C 소스 형태로 배포되는 오픈소스 프로그램으로, 윈도우 환경 또는 리눅스 환경에서의 컴파일이 필요합니다.

사운드폰트 파일을 TiMidity++ 에 적용하기 위해서는 사운드폰트를 위한 TiMidity++ 용 설정 파일이 필요하므로, 사운드폰트 파일로부터 이 설정 파일을 생성할 필요가 있습니다.

(3) 사운드폰트가 적용된 WAVE 파일을 MP3 파일로 변환하기

WAVE 파일을 MP3 파일로 변환하는 데에 가장 많이 쓰이는 도구는 LAME 입니다. 마찬가지로, 리눅스 환경에서 이를 사용하기 위해서는 컴파일이 필요합니다. 윈도우 환경에서는 이미 컴파일된 바이너리를 쉽게 구할 수 있으므로 굳이 컴파일이 필요하지 않습니다.

3. MML 을 MIDI 로 변환하기

일반적인 윈도우 환경이라면, 3ML Editor 와 같은 프로그램을 사용하여 MML 을 MIDI 로 변환할 수 있습니다. 그러나 웹 환경 등에서 서비스하기 위해서는 커맨드 환경에서 사용할 수 있는 별도의 도구가 필요합니다. 이 장에서는 이 작업을 위한 Perl 스크립트 코드를 실제로 작성할 것입니다.

다른 언어의 경우에는 모르겠으나, Perl 의 경우 손쉽게 MIDI 파일을 다룰 수 있는 모듈인 MIDI 가 CPAN 을 통해 배포되고 있으므로, MIDI 파일을 핸들링하기가 매우 쉽습니다.

오해를 막기 위해 덧붙이자면, MIDI 모듈은 MML 코드를 직접 MIDI 파일로 변환해주는 함수를 제공하지는 않습니다. 다만 MIDI 파일 및 데이터를 다루는 여러 가지 함수 및 메서드를 제공하므로, 이들 함수와 메서드를 이용하여 MML 코드를 MIDI 데이터로 변환하기 위한 변환 알고리즘을 설계하고 설계된 알고리즘을 Perl 코드로 구현해야만 합니다.

만약 이 프로그램을 다른 프로그램과 연동하려는 경우에는, 해당 프로그램과 이 프로그램 사이의 데이터 교환을 어떤 방식으로 할지도 결정해야 하지만, 이 부분은 이 문서에서 따로 다루지 않습니다.

(1) MIDI 모듈 설치하기

MIDI 모듈은 Perl 기본 모듈이 아니므로 보통 설치되어 있지 않습니다만, CPAN 을 통해 간단히 설치 가능합니다. 명령행에서 다음과 같이 입력합니다.

cpan MIDI

대부분의 웹호스팅 환경에서는 CPAN 을 이용한 Perl 모듈의 설치 권한을 주지 않기 때문에, 새로운 모듈을 설치하는 데에 제약이 있습니다. 다행스럽게도, MIDI 모듈은 기본적인 Perl 함수 및 모듈만을 이용한 Pure Perl 모듈이므로, Perl 5.8.8 이상이 설치된 일반적인 리눅스 환경이라면 모듈 파일(MIDI.pm) 및 보조 모듈 디렉토리(/MIDI)를 소스 디렉토리에 복사해 넣는 것만으로도 이 모듈을 사용할 수 있습니다.

구체적으로, MIDI-Perl-0.83.tar.gz 파일의 압축을 푼 후, MIDI-Perl-0.83\lib 디렉토리의 파일(MIDI.pm) 및 그 이하의 모든 디렉토리를 Perl 스크립트와 같은 디렉토리에 복사하면 됩니다.

(2) MML to MIDI 제작 1 - 트랙 분리

여기서 다루는 MML 의 형태는 MabiMML 으로서, mml@ 로 시작하여 ; 로 끝나고, 각각의 트랙은 , 기호로 분리됩니다. 예를 들면,

mml@o4cdefgabc,o3ab>cdefga,o2ab>cdefga;

와 같은 형태로 생겼습니다. 기본적인 MML 기호에 대한 설명은 여기서 하지 않겠습니다. 기본적인 MML 기호에 대한 설명이 필요하신 분은 [이 문서]의 첫 부분을 참고하시기 바랍니다.

이 절 이하의 모든 절의 내용은 사람에 따라 다른 알고리즘을 짤 수 있는 부분일 것입니다. 따라서 필자의 설명 및 예제 코드는 필자의 방법일 뿐, 실제의 구현은 다른 알고리즘을 쓸 수 있습니다.

이 절에서는 MML to MIDI 의 첫 단계로, MIDI 파일로 변환하기 위하여 MML 의 각 요소들을 분리하는 부분을 작성합니다. 코드를 편리하게 작성하기 위하여 MML 의 각 요소들을 분리하여 순서대로 배열에 넣는 것으로 하겠습니다.

우선 프로그램의 시작 부분 코드입니다. 원시 MML 코드 데이터를 일정한 구조로 변경하는 부분까지입니다. 위에서 말씀드렸다시피 다른 프로그램과의 연동 부분은 이 문서에서 다루지 않습니다만, 만약 연동 코드를 작성한다면 이 부분에서 설명하는 구조로 파일이나 데이터를 만든 다음 이 스크립트로 보내 주도록 설계하면 될 것입니다.

#!/usr/bin/perl

use strict;     # 현대적인 Perl 코딩이라면
use warnings;   # 이 프라그마는 반드시 포함!
use utf8;       # 코드를 UTF-8로 저장하려면 반드시 포함!

my $mml_data    = {};   # 초기화 (해시 레퍼런스)

#-------------------------------------------------------------------------------
# 데이터 구조: [악기 번호]|[보컬악기 번호]|[원시 MML 코드][줄바꿈]
# 한 줄에 하나의 MML 이 들어갈 수 있고, 여러 개를 포함하면 합주 구현이 된다.
# 모든 악기/보컬악기의 경우 없으면 -1 값을 갖도록 한다.
# 데이터를 연동하는 부분은 이 코드에서 제외되어 있으므로,
# 프로그램 코드 내에 위 형식에 맞추어 실험용 데이터를 넣는다.
#-------------------------------------------------------------------------------

my $mml_rawdata = qq{
    # 테스트용 MML 코드는 4분여의 6중주 곡으로 너무 길므로 별도 텍스트 파일로
    # 기록해 두었습니다. 테스트 시 이 부분에 복사/붙여넣기 해 주시면 됩니다.
};

#-------------------------------------------------------------------------------
# 각 MML 코드의 1,2,3번째 트랙은 악기 트랙, 4번째 트랙이 존재한다면 보컬 트랙. 
# 따라서 트랙 수가 4 라면 마지막 트랙은 보컬 악기로 간주하여 두 번째 지정 악기 
# 에 배정한다. 
#
# 미디의 입장에서는 이 트랙이 어떤 악기로 연주되느냐가 중요할 뿐, 몇 번째 악보
# 인가의 여부는 전혀 중요하지 않으므로 전체 MML 악보를 악기 중심으로 헤쳐모여
# 하는 것.
#-------------------------------------------------------------------------------

{
    # 이 블록에서 모든 MML 은 해체되어 악기 기준으로 트랙별로 분리된다.
    # $mml_data 는 해시 레퍼런스,
    # $mml_data->{악기 번호} 의 구조로 데이터가 들어오며 (역시 해시 레퍼런스),
    # $mml_data->{악기 번호}->{data} 아래에 트랙 데이터가 배열(레퍼런스)로 기록
    # $mml_data->{악기 번호}->{channel} 은 그 악기가 사용할 채널 번호 (스칼라)

    my $used_channel = 0;

    foreach my $tempStr ( split /\n/, $mml_rawdata ) {

        $tempStr =~ s/\s+//g;
        if ( !$tempStr || $tempStr eq "" ) { next; }

        my( $ins, $voc, $dta ) = split( /\|/, lc($tempStr), 3 );

        my ( $only_mml ) = ( $dta =~ /mml\@([a-z0-9\+\-\#\.\,\>\<\&]+)\;/ );
        my ( @tracks ) = split( /\,/, $only_mml );

        for ( 0..scalar @tracks ) {

            if ( !$tracks[$_] || $tracks[$_] eq "" ) { next; }

            my $patch;

            if ( $_ == 3 ) {
                $patch = $voc;
            }
            else {
                $patch = $ins;
            }

            if ( not exists $mml_data->{$patch} ) {
                $mml_data->{$patch}->{data} = [];               # 배열로 초기화
                $mml_data->{$patch}->{channel} = $used_channel; # 채널 배정
                $used_channel++;

                if ( $used_channel == 9 ) { $used_channel++ }   # 드럼 패스
            }

            push @{ $mml_data->{$patch}->{data} }, $tracks[$_];     # 추가
        }
    }
}

코드에 최대한 설명을 달았습니다만, Perl 코드를 읽기 어려워하시는 분께서는 좀 어려우실 수도 있겠습니다. 그렇다고 이 문서에서 Perl 기본 문법을 설명하고 있을 수도 없으니, 이 점은 양해해 주십시오.

대부분의 데이터 구조에 레퍼런스를 쓰고 있는데, 일반적으로 배열이나 해시의 레퍼런스는 Perl 초급 수준의 내용에선 벗어난다고 봅니다. 허나 일정한 구조를 가진 데이터를 만드는 데에 레퍼런스만큼 편리한 게 없어서 말이죠. 레퍼런스가 뭐냐.. 라고 물으신다면 다른 언어의 포인터.. 그러니까 그 값 자체가 아니라, 그 값의 형태와 저장 위치 정보를 가지고 있는 변수.. 라고 표현하면 되려나요? 따라서 레퍼런스로 연결된 실제 변수의 값을 읽어내려면 디레퍼런스를 해서 읽어내야 합니다. 이것 참, 설명이 너무 어렵네요. 저도 피상적으로만 이해하고 있는 거라...

여기서 미디의 기본적인 트랙과 채널에 관한 이야기를 안 할 수가 없겠네요. 저도 이 부분이 매우 알기 어려운 부분이었는데, 간단히 말씀드리면 미디의 채널은 곧 악기이고, 트랙은 그 악기로 연주되는 한 줄짜리 악보입니다.

기본적인 표준 미디의 제한 상, 하나의 미디 파일에 채널은 최대 16개로 제한되어 있습니다. 즉 한 악기당 하나의 채널을 배정한다고 할 때 악기 16개를 쓸 수 있다는 이야기죠. (사실은 드럼 채널 한 채널을 빼야 하기 때문에, 최대 15개입니다.) 한 채널에 여러 개의 악기를 지정할 수는 없지만, 여러 채널에 동일한 악기를 지정할 수는 있습니다.

반면 트랙은 채널의 하위에 종속되어 있으며, 하나의 채널에 기록할 수 있는 트랙의 수는 제한이 없습니다. 물론 연주는 자신이 속한 채널에 지정된 악기로 하게 되죠.

위 코드에서도, 새로운 악기가 나올 때마다 사용할 채널을 앞에서부터 배정하고 있는 것을 알 수 있을 것입니다. 바로 이 부분이죠.

if ( not exists $mml_data->{$patch} ) {
    $mml_data->{$patch}->{data} = [];           # 배열로 초기화
    $mml_data->{$patch}->{channel} = $used_channel; # 채널 배정
    $used_channel++;

    if ( $used_channel == 9 ) { $used_channel++ }   # 드럼 패스
}

만약 악기($patch 에 악기 번호가 들어 있습니다.)의 해시 레퍼런스가 지정되어 있지 않다면 해당 악기가 사용할 해시 레퍼런스를 생성하고 그 구조를 지정해 주는 부분인데, 그 뒤쪽에 채널을 0부터 시작해서 순서대로 배정을 해 주고 있죠. 채널 값에 9를 지정하지 않는 이유는 10번 채널(0부터 시작하므로 9)이 일반적으로 드럼 채널로 지정되어 있기 때문에 건너 뛰는 것입니다.

다만, 위 코드에서 빠진 부분은, 만약 악기에 채널을 배정하다가 15를 넘어가버린 경우에 대한 처리입니다. 위 코드에서는 채널 번호가 15를 넘어가는지를 검사하지 않기 때문에 만약 악기 수가 15개를 넘어간다면 16, 17, 18, ... 등등 계속 할당합니다. 이렇게 해서 생성된 미디 파일이 제대로 연주될지는 저도 모르겠네요.

각설하고, 여기까지 왔으면, 이제 원자료의 MML 은 $mml_data 해시 레퍼런스 아래에 악기별로 정렬되어 있을 것입니다. 간단히 그림으로 표현하면 이렇겠죠.

$mml_data ----- 
            {악기1} 
               ----- {data}
                        ----- [mml 트랙 1]
                        ----- [mml 트랙 2]
                        -----    ...
               ----- {channel} = 0~15 (9 제외) 값
            {악기2}
               ----- {data} (배열 레퍼런스)
               ----- {channel}
            {악기3}
             ...

(3) MML to MIDI 제작 2 - 트랙별로 MML 을 MIDI 악보로 변환

이제 이렇게 만들어진 데이터 구조를 가지고 실제 처리에 들어갑니다. 이 코드는 위 코드 바로 다음에 계속되는 코드입니다.

my $midi_score_arrayref = [];    # 미디 악보 데이터를 기록하는 배열 레퍼런스

foreach my $patch ( sort { $a <=> $b } keys %{ $mml_data } ) {

    if ( $patch eq "-1" ) { next; } # -1 은 촉수엄금

    ## 채널의 시작 선언 및 사용할 악기를 기록한다. ##

    push @{ $midi_score_arrayref },
        ['patch_change', 0, $mml_data->{$patch}->{channel}, $patch];

    my $alloc_channel = $mml_data->{$patch}->{channel};

    ## 해당 악기 밑에 달린 트랙을 모두 미디 악보로 변환 ##

    foreach my $track ( @{ $mml_data->{$patch}->{data} } ) {

        my $array_ref = convMMLtoMIDIscore( $track, $alloc_channel );

        push @{ $midi_score_arrayref }, @{ $array_ref };
    }
}

반복문이 중첩되고 있지만 뭐 어쩔 수 없죠(^^). 프로그램 짤 때 기피하는 것 중의 하나가 루프가 중첩되는 것이라고 하는데, 이 경우는 뭐...

대략 살펴보면, 우선 데이터 구조($mml_data)에서 악기 번호 순서대로 하나씩 데이터를 불러낸 후(첫 번째 foreach문), 악기와 사용할 채널을 선언해서 미디 악보 데이터 배열($midi_score_arrayref 레퍼런스)에 기록해 줍니다. (push 함수를 호출하는 부분은 바로 다음에 MIDI::Score 에서 사용하는 미디 악보 형식에 관한 부분을 보시면 의미를 아실 수 있습니다.) 그리고 그 악기 이하에 등록된 트랙 데이터를 마찬가지로 순서대로 하나씩 불러내서(두 번째 foreach문) MIDI 악보 형태로 변환한 후(convMMLtoMIDIscore 서브루틴), 역시 미디 악보 데이터 배열($array_ref 레퍼런스에 먼저 저장한 후 그 내용을 $midi_score_arrayref 레퍼런스에 push 해서 덧붙여 저장)에 차곡차곡 밀어넣게 되죠.

그렇다면 이 프로그램의 핵심 부분인 convMMLtoMIDIscore( Track, Channel ); 서브루틴을 작성해 봐야죠. 이 서브루틴은 트랙 MML 데이터와, 이 트랙이 속해 있는 채널 번호를 인수로 받고, 작성된 악보 배열 구조를 돌려줍니다. (이 점을 확실히 하기 위해 이 서브루틴의 반환값을 받는 변수를 $array_ref 라고 지어뒀습니다.)

sub convMMLtoMIDIscore {

    my $mml_track = shift;
    my $channel   = shift;
    my @midi_score;    # 이 배열의 레퍼런스를 반환하게 됩니다.

    my @mml_item = splitAllMMLitem( $mml_track );   # 요소별로 분리

    my $CurOctave   = 4;    # Default On 값 (숫자로 간주해도 된다.)
    my $CurLong     = "4";  # Default Ln 값 (절대 숫자 취급 금지.)
    my $CurVolume   = 8;    # Default Vn 값 (숫자로 간주해도 된다.)

    my $CurPosition = 0;    # 현재 위치 확인용 임시 스칼라 변수
    my $tieFlag     = 0;    # 붙임줄 처리를 위한 임시 스칼라 변수

    my $NoteScale   = "";   # 음 높이를 기록하는 임시 스칼라 변수
    my $NoteLong    = "";   # 음 길이를 기록하는 임시 스칼라 변수

    ## 트랙 시작! ##

    foreach my $item ( @mml_item ) {

        ## 음표에 바로 연결되지 않는 설정 코드들 - L, T, V #####################

        if ( substr( $item, 0, 1 ) eq "l" ) {

            ## L: 기본 음길이 설정 #############################################

            my ( $tempNumber ) = ( $item =~ /l([0-9.]+)/ );

            if ( defined $tempNumber ) {
                $CurLong = "$tempNumber";   # 반드시 문자 취급!
            }

            next;
        }
        elsif ( substr( $item, 0, 1 ) eq "" ) {

            next;   # 사실 있을 수 없지만...
        }
        elsif ( substr( $item, 0, 1 ) eq "t" ) {

            ## T: 템포 설정 (Tnn 을 미디의 템포 기준으로 변경해줘야..) #########

            my ( $tempNumber ) = ( $item =~ /t([0-9]+)/ );

            if ( defined $tempNumber ) {

                my $q_tempo = ( 60 / $tempNumber ) * 1000000;

                push @midi_score, ['set_tempo', $CurPosition, $q_tempo];
            }

            next;
        }
        elsif ( substr( $item, 0, 1 ) eq "v" ) {

            ## V: 음 크기 설정 #################################################

            my ( $tempNumber ) = ( $item =~ /v([0-9]+)/ );

            if ( defined $tempNumber ) {

                $CurVolume = $tempNumber;
            }

            next;
        }

        ## 쉼표 : R ############################################################

        elsif ( substr( $item, 0, 1 ) eq "r" ) {

            my ( $tempNumber ) = ( $item =~ /r([0-9.]+)/ );

            ## 쉼표의 길이 확인 ################################################

            my $Long;

            if ( defined $tempNumber ) {

                $Long = $tempNumber;
            }
            else {

                $Long = $CurLong;
            }

            ## 쉼표의 길이를 미디에 맞춘 포지션 길이로 변환 ####################

            if ( $Long =~ /\./ ) {

                my ( $tempNumber2 ) = ( $Long =~ /([0-9]+)\./ );

                if ( defined $tempNumber2 ) {

                    $Long = $tempNumber2;

                    $Long = 96*4 / $Long;
                    $Long *= 1.5;
                }
                else {

                    $Long = $CurLong;

                    $Long = 96*4 / $Long;
                    $Long *= 1.5;
                }
            }
            else {

                $Long = 96*4 / $Long;
            }

            ## 쉼표 길이만큼 절대 포지션을 뒤로 이동 ###########################

            $CurPosition += $Long;

            ## 쉼표의 경우 $tieFlag 값을 무시해도 된다. 고로 초기화. ###########

            if ( $tieFlag == 1 ) {

                $tieFlag = 0;
            }

            next;
        }

        ## 붙임줄 - & ##########################################################

        elsif ( substr( $item, 0, 1 ) eq "&" ) {

            ## 붙임줄로 연결된 음표는 바로 앞의 음길이를 연장하여 표현한다. ##

            $tieFlag = 1;

            next;
        }

        ## 소스 옥타브 - O, <, > 처리 ##########################################

        elsif ( substr( $item, 0, 1 ) eq "o" ) {

            my ( $tempNumber ) = ( $item =~ /o([0-9])/ );

            if ( defined $tempNumber ) {

                $CurOctave = $tempNumber;
            }

            next;
        }
        elsif ( substr( $item, 0, 1 ) eq "<" ) {

            $CurOctave--;

            next;
        }
        elsif ( substr( $item, 0, 1 ) eq ">" ) {

            $CurOctave++;

            next;
        }

        ## 음표 ################################################################

        elsif ( substr( $item, 0, 1 ) eq "n" ) {

            ## N: 절대음높이 지정 음표 #########################################

            ## 음높이 ##

            my ( $tempNumber ) = ( $item =~ /n([0-9]+)/ );

            if ( defined $tempNumber ) {

                $NoteScale = $tempNumber;
            }

            ## 음길이: $CurLong 값을 그대로 쓴다. ##

            $NoteLong = $CurLong;
        }
        else {

            ## CDEFGAB: 일반 음표 ##############################################

            ( $NoteScale, $NoteLong ) = changeNoteStringToNumber( $CurOctave, $item );

            if ( $NoteLong eq "" ) {
                $NoteLong = $CurLong;
            }
        }


        ## 음표 일괄 처리 (공통) ###############################################

        #----------------------------------------#
        # 'note', 위치, 길이, 채널, 음높이, 볼륨 #
        #----------------------------------------#

        ## 음 길이 ##

        if ( $NoteLong =~ /\./ ) {

            my ( $tempNumber ) = ( $NoteLong =~ /([0-9]+)\./ );

            if ( defined $tempNumber ) {

                $NoteLong = $tempNumber;
            }
            else {
                
                # 여기서 숫자가 안 잡히면 . 만 존재하는 것이므로.. #

                $NoteLong = $CurLong;
            }

            $NoteLong = 96*4 / $NoteLong;
            $NoteLong *= 1.5;
        }
        else {

            $NoteLong = 96*4 / $NoteLong;
        }

        ## 이 전 음표에서 이음줄이 있었다면 바로 앞 음표의 음길이만 늘려준다. ##

        if ( $tieFlag == 1 ) {

            my $last_off = [];
               $last_off = pop @midi_score;

            $last_off->[2] += $NoteLong;

            push @midi_score, $last_off;

            $tieFlag = 0;   # 플래그 값 해제
        }

        ## 새로운 음표라면 형식에 맞추어 값을 써준다. ##

        else {

            #------------------------------------------------------------------#
            # * MabiMML 의 Nnn 값은 실제 미디의 음높이 지정값보다 한 옥타브가  #
            #   낮게 설정되어 있으므로, 12를 더해서 기록해준다.                #
            # * MabiMML 의 Vnn 값은 0~15, 미디의 Velocity 값은 0~127. 따라서   #
            #   비율적으로 맞추기 위해 8을 곱해서 기록한다. (따라서 Max=120)   #
            #------------------------------------------------------------------#

            push @midi_score,
            ['note' , $CurPosition, $NoteLong, $channel, $NoteScale+12, $CurVolume*8],
            ;
        }

        ## 기록한 음표 길이만큼 현재 위치 이동 ##

        $CurPosition = $CurPosition + $NoteLong;

        $NoteScale  = "";   # 초기화 안 해도 되지만..
        $NoteLong   = "";   # 확실히 하기 위해서...
    }

    ## 트랙 종료! ##

    push @midi_score, ['end_track' , $CurPosition ];

    return \@midi_score;
}

코드 앞부분에 정의한 옥타브($CurOctave), 기본 음길이($CurLong), 음크기($CurVolume) 값은 루프를 돌면서 계속 변경될 값이지만, 따로 지정하지 않은 상태에서 기본값이 저렇기 때문에 미리 넣어둔 것입니다. 특히 기본 음길이(음표나 쉼표의 길이를 지정하지 않은 경우 이 음길이로 자동으로 판단됩니다)의 값을 숫자가 아닌 문자열 문맥으로 쓰고 있음을 유의해서 보십시오. (기본 길이로 점음표 길이를 지정할 수 있기 때문에, 숫자 문맥이 아닌 문자열 문맥으로 써야 합니다. Perl 의 편리한 점이죠. 스칼라 변수를 들어가는 값에 따라 알아서 취급을 달리해 주니까요. 물론 어떤 분들은 Perl 의 이런 점을 싫어하기도 합니다.)

일단 위 코드에 대한 설명을 일일이 하는 것은 좀 무리가 있고 하니, 위 코드가 어떤 식으로 동작하는지에 대한 설명으로 대체하겠습니다. 이 설명을 보시고 위 코드를 읽으시면 이해 못 할 코드가 아닙니다.

MIDI::Score 모듈이 사용하는 미디 악보의 구조는 각각의 미디 요소 정보를 순서대로 배열에 기록한 배열 레퍼런스입니다. 예를 들면 이렇게 생겼습니다.

['patch_change', 0, 1, 8],
['set_tempo', 0, 500000],
['note', 0, 96, 1, 25, 96],
['note', 96, 96, 1, 29, 96],
['note', 192, 96, 1, 27, 96],
['note', 288, 192, 1, 20, 96],
['note', 576, 96, 1, 25, 96]

[] 기호 안에 들어 있는 내용이 모두 배열이고, 이 배열 데이터의 레퍼런스로 이루어진 또 하나의 큰 배열이 그 밖에 있습니다. 말이 꼬이는데, 이런 겁니다. @midi_score 라는 배열의 각각의 구성요소값은 또 다른 배열을 가리키는 일종의 포인터이고, 그 배열 내부의 또 다른 배열이 바로 [ ] 내의 각 값들을 요소로 갖는 배열이라는 거죠.

MIDI::Score 모듈이 사용하는 모든 미디 요소의 설정에 대해서는 [이 문서]를 참조하시고, 일단 여기에서 사용하고 있는 미디 요소의 구조에 대해서만 따로 적어두겠습니다.

  • ['patch_change', 시작 위치, 채널, 악기 번호] : 이 채널에서 사용할 악기를 지정합니다. 이 부분은 이 스크립트에서는 convMMLtoMIDIscore 서브루틴에 들어오기 전에 이미 지정되고 있었죠. 참고로 시작 시간과 길이에 관한 숫자는 절대 시간이 아니고, 96을 4분음표의 길이로 했을 때의 연주 길이입니다. 예를 들면 4분 음표라면 96, 2분 음표라면 192, 온음표라면 384 가 되죠. 즉 4분의 4박자를 기준으로 한 마디가 지나갔을 때의 현재 위치는 384가 됩니다. (왜 96이냐면, 그렇게 설정할 것이기 때문입니다. 왜 96인지에 대한 의문은 미디를 잘 아시는 분께 물어보십시오.)
  • ['set_tempo', 시작 위치, 속도] : 연주할 속도를 의미합니다. 악보의 제일 앞에 붙어 있는 기호 생각나시나요? 예를 들면 T=85 라던가 T=120 이라고 써 있는. 혹은 (4분 음표 그림)=120 이라고 되어 있기도 했을걸요. 이 속도 표시는 1분에 4분 음표를 이 갯수만큼 연주할 수 있는 속도로 연주를 하라는 의미입니다. 위 예시 악보는 T=120 을 지정한 것인데요. 근데 숫자가 뭔가 무시무시하죠? 무려 50만이라니. 이 숫자는 60을 T=nnn 값으로 나누고 거기에 1,000,000 을 곱한 값입니다. 밀리 세컨드도 아니고 무려 마이크로 세컨드 값으로 표시를 하다 보니 백만이라는 엄청나게 큰 숫자가 곱해지게 된 것입니다.
  • ['note', 시작 위치, 연주 길이, 채널, 음 높이, 음 크기] : 가장 중요한, 음표입니다. 시작 위치와 연주 길이에 대한 건 위와 같고, 채널은 이 음표가 속한 채널을 의미합니다. (채널 값을 서브루틴에 넘겨준 이유가 음표 때문이었습니다.) 음 높이는 정수값으로, 옥타브 0 의 C 값을 0으로 정하고 1도가 올라갈 때마다 1씩 더해서 기록합니다. (즉, 흔히 말하는 가온 다=옥타브 4의 C음은 48이 됩니다.) 문제는 미디에서 사용하는 음높이 값이 MabiMML 에서 사용하는 숫자값보다 한 옥타브가 높습니다. 즉 MabiMML 에서 48 로 표현되는 음높이는 실제로 미디로 옮겨지면 60이라는 값을 가져야 한다는 거죠. 이 부분에 대한 처리도 코드에서 하고 있는 것을 확인하실 수 있을 것입니다. 음의 크기(=Volume, Velocity)는 0~127의 숫자로 표시되는데, 0이면 소리가 나지 않는다는 것을 의미합니다. MabiMML 은 0~15의 값을 갖기 때문에, 이를 같은 비율로 맞추기 위해서 볼륨 값에 일률적으로 8을 곱해서 최대값이 120이 되도록 했습니다.
  • ['end_track', 종료 위치] : 이 채널에서 하나의 트랙이 끝났음을 알리는 종료 표시자입니다. 마지막 음표의 연주가 끝난 시점을 종료 위치에 기록해 주면 되는데요. 이 부분은 나중에 한 번 더 수정할 필요가 있습니다. 일단 이 정도로만 넘어가겠습니다.

그럼 쉼표는요? 아까 예시 악보에서 제일 끝 부분을 보시죠.

['note', 288, 192, 1, 20, 96],
['note', 576, 96, 1, 25, 96]

위치 288에서 192만큼을 연주하면 현재 위치는 480이죠. 그런데 다음 음표의 시작 위치는 576입니다. 480 에서 576 사이의 96 만큼의 길이(=4분 음표의 길이)동안에는 이 트랙에서 소리를 내지 않는다는 의미가 됩니다. 저기에 4분 쉼표가 숨어 있는 거죠.

각설하고, MML 을 위에서 설명한 MIDI::Score 미디 악보 형태로 변환을 하기 위해서는 MML 의 각 요소를 순서대로 읽어서 변환을 해야 합니다. 그 준비 작업으로, 서브루틴으로 넘어온 트랙 데이터 뭉치를 각각의 MML 요소별로 잘라서 배열에 넣어 주게 됩니다. 이렇게 잘려진 요소 배열을 가지고 위의 변환 작업을 하게 되죠. 이렇게 잘라주는 역할을 하는 서브루틴이 바로 splitAllMMLitem( TrackData ) 서브루틴입니다. (위 서브루틴의 앞 부분에서 이 서브루틴이 호출되고 있는 것을 보실 수 있습니다. 반환값은 배열이고요.) MML 의 각각의 요소들의 바로 앞에 개행문자를 넣어준 후, 이 개행문자를 기준으로 모두 잘라서 배열에 순서대로 넣어 돌려주게 되죠. 간단한 서브루틴이므로 이해하기는 어렵지 않으실 것입니다. Perl 의 정규식을 잘 모르시는 분을 위해 간단히 하나만 말씀드리자면 s/[문자열1]/[문자열2]/g 라는 정규식은 지정한 문자열 내의 모든 [문자열1] 을 [문자열2] 로 치환하라는 의미의 정규식입니다. 그리고 \n 이라는 녀석은 줄바꿈 문자를 의미하고요. 나중에 줄바꿈 문자를 기준으로 딱딱 잘라서(split) 순서대로 배열에 넣고 있는 것도 알아 보시겠죠.

sub splitAllMMLitem {

    my $data = shift;   # 트랙 데이터 (스칼라 변수)
    my @segmented;

    ## CDEFGAB/R 코드 ##########################################################

    $data =~ s/c/\nc/g;
    $data =~ s/d/\nd/g;
    $data =~ s/e/\ne/g;
    $data =~ s/f/\nf/g;
    $data =~ s/g/\ng/g;
    $data =~ s/a/\na/g;
    $data =~ s/b/\nb/g;

    $data =~ s/r/\nr/g;

    ## L,N,O,T,V 코드 ##########################################################

    $data =~ s/l/\nl/g;
    $data =~ s/n/\nn/g;
    $data =~ s/o/\no/g;
    $data =~ s/t/\nt/g;
    $data =~ s/v/\nv/g;

    ## <, >, & 기호 ############################################################

    $data =~ s/\/\n\>/g;
    $data =~ s/\&/\n\&/g;

    ## 배열에 집어넣기 #########################################################

    ( @segmented ) = split( /\n/, $data );

    return @segmented;
}

또한, 보통 MML 코드의 음표는 c,d,e,f,g,a,b 의 문자로 기록되는데, 분할된 이 음이름 요소를 받아서 음높이 숫자로 바꾸어 돌려주는 서브루틴이 changeNoteStringToNumber( Octave, Item ) 입니다. changeNoteStringToNumber( 4, "c4" ); 와 같이 넘겨주면 [ 48, 4 ] 와 같이 음 높이와 음 길이를 숫자로 바꾸어서 돌려줍니다. 마찬가지로, 그다지 설명할 필요가 없는 서브루틴입니다. 앞에서 설명한, 옥타브 0의 C를 0으로 시작해서 음높이가 1도 올라갈 때마다 1씩 증가한다는 사실만 알고 계시면 이 코드도 쉽게 이해가 되실 것입니다.

sub changeNoteStringToNumber {

    my $Octave = shift;         # 옥타브 값 (숫자)
    my $note   = shift;         # 음표 기호
    my $Option = shift || 1;    # 옵션 (1이 아니면 nn 값만 돌려준다.)

    my $tempScale;
    my $tempSharp;
    my $tempLong;
    my $tempBaseC;
    my $Scale;

    ( $tempScale ) = ( $note =~ /([cdefgab])/ );    # 계명
    ( $tempSharp ) = ( $note =~ /([\+\-\#])/ );     # 샵/플랫
    ( $tempLong  ) = ( $note =~ /([0-9.]+)/ );      # 음길이

    if ( !$tempSharp ) { $tempSharp = ""; }
    if ( !$tempLong )  { $tempLong = ""; }

    ## (O0 의 C 가 N0, O1 C가 N12, ...)
    ## 실제 사용 가능한 N코드값의 범위는 0~99 이다. 
    ## 물론 이 코드는 100을 넘어가더라도 신경 안 쓰고 값을 돌려준다.

    $tempBaseC = $Octave * 12;

    if    ( $tempScale eq "c" ) { $Scale = $tempBaseC+0; }
    elsif ( $tempScale eq "d" ) { $Scale = $tempBaseC+2; }
    elsif ( $tempScale eq "e" ) { $Scale = $tempBaseC+4; }
    elsif ( $tempScale eq "f" ) { $Scale = $tempBaseC+5; }
    elsif ( $tempScale eq "g" ) { $Scale = $tempBaseC+7; }
    elsif ( $tempScale eq "a" ) { $Scale = $tempBaseC+9; }
    elsif ( $tempScale eq "b" ) { $Scale = $tempBaseC+11; }
    else {}

    if ( $tempSharp eq "+" ) { $Scale++; }
    elsif ( $tempSharp eq "\#" ) { $Scale++; }
    elsif ( $tempSharp eq "-" ) { $Scale--; }
    else {}


    if ( $Option == 1 ) {

        return ( $Scale, $tempLong );   # nn, 길이
    }
    else {
        return $Scale;                  # nn 만.
    }
}

아무튼, 이렇게 해서 MML 트랙 내의 모든 요소를 MIDI::Score 의 미디 악보 형식으로 변환했고, 그 내용은 @midi_score 배열에 차곡차곡 쌓였습니다. 이제 이 값을 레퍼런스 형태로 되돌려 주면 되네요. (return \@midi_score;) 스칼라/배열/해시 변수를 나타내는 기호 앞에 \ 기호를 붙이면 그 레퍼런스를 의미하게 됩니다.

한참 전에 있었던, convMMLtoMIDIscore() 서브루틴을 호출하는 부분을 다시 가져와 보겠습니다.

    ## 해당 악기 밑에 달린 트랙을 모두 미디 악보로 변환 ##

    foreach my $track ( @{ $mml_data->{$patch}->{data} } ) {

        my $array_ref = convMMLtoMIDIscore( $track, $alloc_channel );

        push @{ $midi_score_arrayref }, @{ $array_ref };
    }

convMMLtoMIDIscore() 에서 하나의 트랙을 MIDI::Score 악보로 변환해서 돌려주면, 이 반환값을 배열 레퍼런스 $array_ref 에 저장합니다. 그리고 이 $array_ref 값은 이 Scope (foreach 구문 안쪽) 에서만 유효한 값이기 때문에 곧 사라질 운명이므로, 그 전에 push 함수를 써서 스코프 바깥에서도 유효한 $midi_score_arrayref 에 덧붙여 기록해 둡니다. (바로 $midi_score_arrayref 에 저장하지 않고 $array_ref 에 먼저 저장하는 이유는 이미 배열 레퍼런스 $midi_score_arrayref 에 값이 들어 있기 때문입니다. 안전하게 기존 배열 뒤에 값을 덧붙이기 위해서 임시로 $array_ref 배열 레퍼런스에 저장한 후 push 함수를 호출해서 덧붙이는 거죠.)

이 루프가 끝나면 전체 악보를 MIDI::Score 미디 악보로 변환한 데이터가 $midi_score_arrayref 에 쌓여 있겠네요.

(4) MML to MIDI 제작 3 - 트랙 종료 일치시키기

이미 MML 을 MIDI::Score 미디 악보로 변환한 상태고, 이제 이것을 미디 파일로 저장하는 것은 정말 쉽습니다. 그런데, 저장 전에 해야 할 일이 하나 있습니다.

지금 현재 각 트랙의 끝에는 트랙이 종료된 위치가 기록이 되어 있습니다. 그런데, 보통 MabiMML 코드들을 보면 이 위치가 일치하지 않는 경우가 많습니다. 끝을 맞추지 않아도 가장 긴 트랙에 맞추어서 연주가 되기 때문에 여러 가지 이유로 그냥 냅두는 경우가 많은 거죠. 그런데 미디에선 반대로, 가장 길이가 짧은 트랙에 맞춰서 연주가 끝나 버립니다. 그렇기 때문에, 완성된 MIDI::Score 악보를 다시 한 번 검사해서, 가장 긴 트랙 종료 시점에 맞추어서 모든 트랙 종료 위치를 수정해 줘야 한다는 거죠.

아래는 그렇게 수정을 위한 코드입니다.

$midi_score_arrayref = adjustTrackEndPosition( $midi_score_arrayref );

이 코드는 루프가 끝난 바로 뒤에 추가하시면 됩니다. 그리고 실제 일을 하는 adjustTrackEndPosition( reference ) 서브루틴은 아래와 같습니다.

sub adjustTrackEndPosition {

    my $array_ref = shift;
    my $longest_track = 0;
    my @new_array;

    ## 우선 가장 마지막 끝나는 위치를 확인해야 한다. ###########################

    foreach my $item_array_ref ( @{ $array_ref } ) {

        if ( @{ $item_array_ref }[0] eq "end_track" ) {

            if ( $longest_track < @{ $item_array_ref }[1] ) {
                $longest_track = @{ $item_array_ref }[1];
            }
        }
    }

    ## end_track 값을 가장 긴 값으로 모두 교체해 준다. #########################

    foreach my $item_array_ref ( @{ $array_ref } ) {

        if ( @{ $item_array_ref }[0] eq "end_track" ) {

            push @new_array, ['end_track' , $longest_track ];
        }
        else {

            push @new_array, $item_array_ref;
        }
    }

    return \@new_array;
}

내용은 별거 없습니다. 전체 악보를 뒤져서 'end_track' 을 모두 찾아낸 후, 그 값들 중 가장 큰 값으로 모든 'end_track' 위치 값을 수정해서 다시 돌려주는 겁니다.

(5) MML to MIDI 제작 4 - 미디 파일로 저장하기

다 왔습니다. 이제 MIDI::Score 에서 생성한 악보 배열 데이터를 MIDI 모듈을 이용하여 미디 파일로 변환해 주어야 합니다. 이전 절에서 생성한 악보 배열 데이터는 통합하여 $midi_score_arrayref 에 들어 있으므로, 이 악보 데이터를 미디 이벤트 목록으로 변환하고, 이것을 트랙 데이터로 다시 변환한 후, 미디 바이너리 데이터로 변환해서 저장하는 과정을 거치면 됩니다. 이걸 몇 줄의 코딩으로 MIDI 모듈이 다 해 주니 걱정하지 마세요.

use MIDI;

my $score_to_event = MIDI::Score::score_r_to_events_r( $midi_score_arrayref );
my $event_to_track = MIDI::Track->new({ 'events' => $score_to_event });

my $opus = MIDI::Opus->new({
    'format' => 0,
    'ticks' => 96,
    'tracks' => [ $event_to_track ]
});

$opus->write_to_file( "save.mid" );    # 미디 파일 저장

exit;

이게 끝입니다. 성공적으로 save.mid 파일이 생성이 되었을 것입니다. 그리고 이 길고 길었던 코드도 끝났습니다. 위의 스크립트 코드들을 전부 하나의 코드로 합쳐서 저장하고 테스트용 악보를 첨부해서 실행해 보시면 실제로 실행이 잘 되실 것입니다. 하나로 합쳐놓은 예제 코드는 아래의 링크에서 다운로드 받으실 수 있습니다. 예제 테스트 데이터도요.

4. TiMidity++ 를 컴파일하기

생성된 미디 파일을 들어보셨나요? 뭔가 기대와는 다른 좋지 않은 소리가 들릴지도 모르겠습니다. 앞에서 잠깐 언급한 바 있지만, MIDI 파일은 악기의 종류와 음높이 등에 관한 데이터만을 갖고 있을 뿐, 음색에 관한 데이터는 갖고 있지 않습니다. 그래서 운영체제가 갖고 있는 기본적인 가상 악기 데이터를 갖고 소리를 내기 때문에 딱 기계음 그 이상도 이하도 아닌 소리가 들리는 거죠.

그렇다면, 좋은 음색을 갖고 있는 가상 악기를 미디 파일에 적용시켜 주면 더 좋은 소리를 들을 수 있겠네요. 이런 용도로 사용하는 데이터가 바로 사운드폰트라는 녀석입니다. 그리고 이 사운드폰트를 적용해서 우리에게 들려주는 프로그램들이 있습니다만, 제가 아는 녀석 중에 이럴 때 쓰기에 가장 좋은 녀석은 TiMidity++ 라는 프로그램입니다.

TiMidity++ 의 최종 버전은 2.13.3 (안정 버전의 최종 버전은 2.13.0) 으로, 최종 버전의 공개 시기는 2004년 10월입니다. 즉 최근 10년간 전혀 업데이트가 되고 있지 않은 프로젝트입니다만, 리눅스 환경에서 MIDI 파일에 사운드폰트를 적용하기 위해서 이 프로그램을 사용하는 것보다 더 편리한 방법은 제가 아는 한도 내에서는 존재하지 않습니다.

필자의 경우 이 프로그램을 우분투 서버 가상 머신 환경에서 무난히 컴파일할 수 있었으며, Cafe24 의 웹호스팅 환경에서 컴파일 권한을 받은 상태로 컴파일이 가능했습니다. gcc 의 권한을 허용하는 모든 웹호스팅 및 서버 호스팅 환경에서 무난히 컴파일이 가능할 것으로 기대합니다.

대개의 웹 호스팅 업체는 보안상 이유로 웹 호스팅 일반 사용자에게 gcc 권한을 주지 않습니다. 따라서 웹 호스팅 환경에서는 이 문서를 구현하기가 어렵습니다. 다만 필자가 이용하는 Cafe24의 경우에는 서버 호스팅이 아닌 일반 웹 호스팅의 경우에도 별도 신청 시 신청한 시간 동안 gcc 권한을 허용하고 있으므로 이 문서의 구현이 가능했습니다.

한 가지 문제점. 일반적인 가상 머신 환경이나 웹호스팅 등 환경에서는 WAVE OUT 을 위한 DSP 모듈이 설치되어 있지 않은 경우가 많은데, 이 때문에 TiMidity++ 가 실행 시 오류를 발생시킵니다. (컴파일 때는 문제가 없었는데 막상 실행하니 오류가 발생해서 굉장히 당황했었지요.) 우리는 소리를 스피커로 들으려는 것이 아니라, WAVE 파일로 저장하려는 것이기 때문에 실제 동작 과정에서 이 부분이 없어도 상관이 없지만, TiMidity++ 의 설계상 이런 경우에도 일단 DSP 모듈을 찾기 때문에 문제가 생기는 것이라고 하네요.

자신이 설정을 건드릴 수 있는 단독 서버 환경이라면 이를 설정을 통해 해결할 수 있지만(구체적인 해결 방법 문서는 여기), 웹호스팅 환경에서는 이런 해결이 불가능합니다.

그래서 필자의 경우, C 소스의 해당 부분(DSP 목록이 생성되지 않는 경우 오류 메시지를 출력하고 종료하는 부분)을 주석 처리하여 동작하지 않도록 하여 이 문제를 회피했습니다.

이 방법으로 컴파일하게 되면 .WAV 파일로 저장하는 용도 이외의 용도로 TiMidity++ 를 사용할 경우 문제가 발생할 가능성도 생깁니다만, 일단 우리의 사용 용도로는 문제 없이 쓸 수 있게 됩니다.

  1. /timidity/timidity.c 파일을 엽니다.
  2. 다음의 코드를 찾아갑니다. (마지막 Stable 버전인 2.13.0 버전의 경우 4710~4713 라인입니다.)
    if (play_mode == NULL) {
    fprintf(stderr, "Couldn't open output device" NLS);
    exit(1);
    }
  3. 이 부분 전체를 주석처리하거나 삭제합니다. 또는 exit(1); 부분만 주석처리해도 됩니다. (오류 메시지는 출력되지만)
  4. 컴파일을 실행합니다.

대부분의 경우 GNU autotools 가 설치되어 있으므로, 컴파일 과정 자체는 쉽습니다.

./configure
make
make install

첫 줄의 ./configure 는 서버 환경을 검사하여 컴파일을 위한 정보 파일을 만들어내는 스크립트입니다. 쉘 스크립트로 짜여져 있죠. 두 번째 줄의 make 를 통해 컴파일 작업이 이루어지고, make install 명령으로 바이너리가 생성되어 특정 위치에 복사됩니다.

Cafe24 의 경우에는 쉘 스크립트 바이너리로 sh 대신 bash2 가 설치되어 있으므로, 쉘 스크립트 configure 앞에 bash2 를 다음과 같이 명시적으로 지정하여 실행해야 합니다.

bash2 ./configure

그 외의 경우에도, 서버 설정에 따라서 쉘 스크립트 바이너리로 bash 를 명시적으로 지정하여야 하는 경우가 있습니다.

bash ./configure

make install 과정에서 설치 디렉토리가 이상한 곳으로 가지 않도록 파일을 약간 수정하거나 make install 의 옵션을 추가로 주어야 할 수도 있습니다. 아래와 같이 DESTDIR= 옵션을 사용하여 원하는 위치로 지정해 주면 됩니다.

make install DESTDIR=/opt/local

기본 설치 디렉토리는 필자의 우분투 환경의 경우 usr/local/bin 이었습니다. Cafe24 라면 각 유저의 www 디렉토리를 기준으로 하므로, www 디렉토리 아래에 usr/local/bin 디렉토리가 생성됩니다.

일단 바이너리가 생성된 후에는 해당 바이너리를 다른 디렉토리로 옮겨도 동작에 문제가 없으니 바이너리 생성이 완료되면 바이너리만 필요한 디렉토리로 이동시키고, 이 디렉토리 및 초기 컴파일에 쓰였던 디렉토리는 모두 삭제하여도 됩니다.

5. TiMidity++ 를 위한 사운드폰트 파일 설정

이 장의 내용은 적절한 사운드폰트 파일(*.sf2)을 구했다는 것을 전제로 합니다. 만약 .SF2 파일이 아닌 .DLS 파일을 구한 경우에는, 이 .DLS 파일을 .SF2 파일로 변환해야 합니다. .DLS 파일을 .SF2 파일로 변환하는 방법은 여러 가지가 있지만, 대개 다음의 프로그램들을 사용하면 됩니다.

기본적으로 두 프로그램 모두 상용 프로그램입니다. (vsampler 의 경우 free trial 이 가능합니다.) 사용권을 취득하는 방법에 대해서는 별도로 이야기하지 않겠습니다. 돈 주고 사시든지, 어둠의 경로를 통해서 프로그램을 구하시든지...

어쨌거나 이런 과정을 거쳐서 .DLS 파일을 .SF2 파일로 변환하였거나 원하는 .SF2 파일을 구했다면, 이제 이 데이터를 TiMidity++ 에서 사용하기 위한 설정 파일을 생성해야 합니다. 다행히도, 이 설정 파일을 자동으로 생성해 주는 스크립트가 이미 공개되어 있습니다. (이 프로그램은 윈도우용 프로그램입니다!)

예를 들어 사운드폰트 파일명이 soundfont.sf2 라면, 다음과 같이 입력합니다.

cfgforsf -s- soundfont.sf2 timidity.cfg

위 명령은 soundfont.sf2 파일의 설정을 timidity.cfg 파일로 저장하되, 저장 시 별도의 정렬을 하지 말라는 의미입니다.

이제 timidity.cfg 파일을 공용 설정 디렉토리에 복사해야 합니다만, 마찬가지로 웹호스팅 환경에서는 이 디렉토리에 쓰기 권한이 없죠. 따라서, timidity 바이너리가 설치된 디렉토리에 함께 복사해 둔 후, timidity 실행 시 설정 파일의 경로를 지정해 주는 방식으로 오류를 회피하도록 합시다. 또한, 오류를 방지하기 위해 사운드폰트 파일은 TiMidity++ 실행 바이너리 및 설정 파일과 같은 디렉토리에 둡니다.

사운드폰트 파일을 CFG for Soundfont 를 통해 설정 파일을 만든 경우, 종종 설정 파일의 크기가 매우 커지는 경우가 있습니다. 파일을 열어보면 같은 설정이 중복으로 두 번씩 들어있곤 합니다. 이런 경우에는 다른 라인과 중복되는 라인을 일괄적으로 삭제해 주는 유틸리티를 사용해서 정리를 한 번 해 주어야 합니다. 일일이 수작업으로 지워주려면 너무 몸이 고생해요. 몇천 라인일텐데... (이것이 사운드폰트 파일 자체의 문제인지, 아니면 스크립트의 버그인지는 모르겠습니다.) 제 경우는 제가 사용하는 문서 편집 프로그램인 EditPlus 가 이 기능을 갖고 있어서 손쉽게 편집이 가능했습니다.

6. TiMidity++ 명령행으로 사운드폰트 적용하기

명령행에서 다음과 같이 명령을 주면 timidity 바이너리의 구체적인 사용법을 알 수 있습니다.

timidity --help  

그러나, 굳이 이 항목을 다 살피지 않아도, 다음의 옵션만 알고 있으면 변환이 가능합니다.

./timidity -c ./timidity.cfg -OwM -o out.wav save.mid >> ./timidity.log
  • -c 옵션으로 설정 파일의 위치를 지정하며,
  • -o 옵션으로 출력할 WAVE 파일의 경로 및 파일명을 지정합니다. 그 뒤에 읽을 MIDI 파일을 인자로 줍니다.
  • -OwM 옵션은, -Ow 가 RIFF WAV 파일로 출력한다는 의미, 그 뒤의 M 은 MONO 의 의미입니다. (Stereo 로 저장하려면 M 을 빼거나 명시적으로 S 를 붙여주세요.)

즉, 저장된 미디 파일이 save.mid 이고, 이 파일을 out.wav 파일(모노)로 저장할 것입니다.

참고로 >> ./timidity.log 는 리다이렉션으로, 화면으로 출력되는 모든 메시지를 timidity.log 에 저장해 줍니다. 오류 로그 등을 파일로 저장하여 혹시라도 있을지 모를 오류에 대비하는 이외에, 웹 서비스로 연동 시 쓸데없는 라인이 HTML 헤더로 흘러들어가 인터널 서버 에러를 유발하는 것을 방지하기 위한 것입니다.

Perl 의 경우는 system 함수를 이용해서 외부 프로그램을 실행하는 방법이 가장 간단합니다. 예를 들면, TiMidity++ 의 바이너리가 같은 디렉토리에 있다고 가정하면

system qq{./timidity -c ./timidity.cfg -OwM -o out.wav save.mid >> ./timidity.log};

이런 식으로 스크립트에 삽입해 주면 이 작업까지 자동으로 처리할 수 있습니다.

7. WAV 파일을 MP3 파일로 변환하기

이렇게 생성된 WAV 파일은 용량이 매우 크므로, 저장 공간의 문제와 함께 무엇보다 웹 서비스 시 대량의 트래픽을 소모하게 됩니다. 따라서 이 파일을 보다 용량이 작으면서 웹 서비스에 적합한 파일 포맷으로 변환해 줄 필요가 있습니다. 여기서는 일단 LAME 을 이용하여 .MP3 파일로 변환할 것입니다.

LAME 역시 리눅스 환경에서는 C 소스 형태로 배포되므로, 소스를 다운로드 받아서 컴파일해 주면 됩니다. 이 역시 GNU autotools 를 지원하기 때문에 컴파일 방법은 TiMidity++ 를 컴파일할 때와 동일합니다. 만약, 서버에 이미 LAME 바이너리가 설치되어 있다면 그냥 써도 되겠죠. Cafe24 의 경우 bash 대신 bash2 입니다.

bash ./configure
make
make install

필자의 경우 Cafe24 의 컴파일 과정에서는 별다른 문제를 일으키지 않았었습니다만, 가상 머신에 설치한 우분투 환경에서는 make 과정에서 이해할 수 없는 오류가 발생하며 컴파일에 실패하는 문제가 있었습니다. 도무지 이유를 알 수가 없었는데,

bash ./configure

대신

./configure

로 (쉘 스크립트 바이너리를 지정하지 않고) 실행하니 문제가 해결되었습니다. (만약 make 도중 문제가 발생하였다면 컴파일 작업을 진행하던 디렉토리의 내용을 모두 지우고 소스의 압축을 다시 푼 상태에서 ./configure 작업부터 다시 해야 합니다.)

컴파일된 Lame 을 이용하여 .WAV 파일을 .MP3 파일로 만드는 방법은 다음과 같습니다.

lame --quiet --noreplaygain -b 96 out.wav out.mp3 >> ./lame.log
  • --quiet 옵션으로 치명적인 오류 이외의 메시지는 출력되지 않도록 하며,
  • --noreplaygain 옵션으로 Replaygain 을 사용하지 않습니다.
  • -b 96 은 CBR 96KBps 를 의미하며, 뒤의 숫자를 원하시는 대로 128/192/320 등으로 변경하시면 됩니다.

그 외의 옵션들은 앞에서 설명했거나(리다이렉션) 설명이 필요 없는 항목(WAV/MP3 파일명 지정)이므로 넘어갑니다. TiMidity++ 를 실행하듯 이것도 system 함수를 이용하면 Perl 스크립트 내에 때려박을 수 있습니다.

system 함수를 이용해서 외부 프로그램을 두 개 연속으로 실행하는 상황이기 때문에, 만약 웹 서비스에서 게시판 등과 연동해서 처리하는 경우 이 두 프로그램의 처리가 끝날 때까지 기다리게 되면 상당히 긴 시간을 기다리게 되고, 서버의 설정에 따라서는 Timeout 이 뜨게 될 수도 있습니다. 외부 스크립트에서 이 컨버터를 연동하여 호출할 때에, fork() 함수로 프로세스를 복제한 후 exec() 함수를 호출해서 컨버터를 별도 프로세스로 돌려버려면 문제가 어느 정도 해결되긴 할 겁니다. 꼭 Perl 이 아니더라도 fork 와 exec 정도는 지원을 할 테니.. 만약 외부 스크립트도 Perl 이라면, 이를테면 컨버터를 호출하는 부분에서 아래와 같은 코드가 필요하겠죠.

$SIG{CHLD} = 'IGNORE';  # 자식프로세스 반환값을 기다리지 않는다.

my $fork_pid = fork();

if ( defined $fork_pid ) {

	if ( $fork_pid == 0 ) {

		## 자식 프로세스: exec 호출로 컨버터를 실행하고 종료 ##
		## 호출하고 기다리지 않고 바로 종료된다. ##

		exec( qq{./converter.pl $parameter >> converter.log} );
		exit; # 좀비 프로세스 예방용
	}
	else {

		## 부모 프로세스: 저장 완료 메시지를 출력하고 종료 ##

		exit;
	}
}
else {

	## fork 실패. 작업 실패 오류를 출력하는 위치. ##

	exit;
}

다만 웹호스팅 서비스의 경우 서버 관리자가 이런 짓을 하는 걸 그냥 냅둘지 모르겠습니다. 만약 변환 과정에서 오류가 발생한 경우 이 오류 정보를 어떻게 원래 프로세스로 전달할지 부분도 고민해야 하는 부분이고요.

8. 생성된 .MP3 파일의 웹 서비스

이 부분은 기본적으로 인터페이스 또는 타 프로그램과의 연동에 관련된 부분이므로 크게 언급할 내용이 없습니다.

HTML5 표준에서 드디어 멀티미디어 파일의 재생에 관한 표준이 만들어졌습니다. <audio> 태그를 써서 음원 파일을 재생할 수 있게 됐죠. 그래서 드디어 ActiveX 지옥에서 해방되나 싶었는데, 세상이 그렇게 만만하지가 않은 것이 HTML5 를 지원하지 않는 구 버전 브라우저의 경우 이 태그를 사용하면 재생이 되지 않는 문제가 있습니다. 당장 IE 8만 해도 이 태그 쓰면 동작 안 해요. (여기는 대한민국이라서, IE 7이나 8이 의외로 많습니다. 지금은 공식적으로 제발 쓰지 말라고 하는 윈도우 XP 의 경우에는 IE 8이 마지막 버전이죠.) 게다가 HTML5 표준에 있는 사항이라고 해서 크로스 브라우징 관련 문제가 안 생기는 것도 아니예요. 심지어 브라우저마다 재생 가능한 파일 형식에도 차이가 있는 걸요 뭐. 게다가 IE 의 경우에는 IE 9 이후 버전에서 <audio> 태그를 지원을 합니다만, IE 10 등의 버전에서는 또 이것이 제대로 구현이 안 되는 문제를 갖고 있습니다. (뭘 건드린 거냐 대체.) 그래서 IE 10 의 경우에 아래와 같이 회피 코드를 메타 태그로 넘겨주어야 하는데, 또 이 메타 태그 때문에 웹표준 검사에서 문제가 생기곤 하죠. 뭐, 웹표준 검사 통과가 지고지순의 목표는 아닙니다만.

<meta http-equiv="X-UA-Compatible" content="IE=EmulateIE9" />

그래서 HTML5의 <audio> 태그를 사용하여 음원을 링크시키는 경우에도, 이것이 동작하지 않는 브라우저에서는 플래시나 미디어 플레이어 등을 통하여 재생할 수 있도록 우회 코드를 함께 적용하곤 합니다. 대개 자바스크립트를 이용해서 해결하곤 하는데, 이와 관련된 코드는 지난 몇 년간 웹 디자이너들이 머리 쥐어뜯어가며 작업해 놓은 결과물이 웹상에 널려 있으므로 이 코드들을 찾아보시면 되겠습니다. 예를 들면 아래 사이트 같은 곳 말이죠.

요즘처럼 인터넷 속도가 빠른 상황에서는 사실 아무도 신경 안 쓰는 부분이긴 한데, 기본적으로 .MP3 파일은 스트리밍이 가능한 구조로 만들어져 있지 않습니다. 즉, 파일이 완전히 다운로드 된 이후에 재생이 가능한 형식이라는 거죠. 이 부분이 신경쓰인다면 스트리밍이 가능한 파일 형식으로 서비스를 하는 것도 생각해 볼 수 있습니다. 허나 이렇게 하면 또 파일 형식에 따른 재생 가능/불가능 문제가 재등장할 것이 안 봐도 비디오라서.... 저라면 그냥 신경 안 쓰고 말겠습니다. 가장 범용으로 쓸 수 있는 형식이 .MP3 인 건 사실이니까요.

☞ 태그: MML, MIDI, 미디, 사운드폰트, TiMidity++, Lame,

☞ 트랙백 접수 모듈이 설치되지 않았습니다.

☞ 덧글이 2 개 있고, 트랙백이 없습니다.

덧글을 남기시려면 여기를 클릭하십시오.

□ 김경식 님께서 2017-06-03 17:43:20 에 작성해주셨습니다.

안녕하세요 c언어로 악기 프로그램 제작중인 학생입니다. 다름이아니라 미디를 이용해서 피아노와 기타 등은 연주가 되게 했지만 드럼과 같은 경우는 어떻게 해야 하는지 잘 모르겠습니다. 채널을 9번채널을 열어줘야하는건지 채널변경은 어떻게 해야하는건지 알려주시면 감사하겠습니다.

□ 김경식 님께서 2017-06-03 17:44:27 에 작성해주셨습니다.

안녕하세요 c언어로 악기 프로그램 제작중인 학생입니다. 다름이아니라 미디를 이용해서 피아노와 기타 등은 연주가 되게 했지만 드럼과 같은 경우는 어떻게 해야 하는지 잘 모르겠습니다. 채널을 9번채널을 열어줘야하는건지 채널변경은 어떻게 해야하는건지 알려주시면 감사하겠습니다. rudtlr0824@naver.com

[483] < [463] [462] [461] [460] [454] ... [453] ... [452] [450] [449] [446] [445] > [19]

(C) 2000-2018, Owl-Networks. Powered by Perl. 이 페이지는 HTML 5 표준에 따라 작성되었습니다.