알송

알송은 이스트소프트에서 만든 알 시리즈 프로그램 중 하나입니다. 특징으로는 재생하는 음원의 가사를 등록하면 다른 사람도 같이 가사와 함께 감상이 가능하다는 점입니다. 그런데 문득 호기심이 생겼습니다.

알송에 등록된 가사를 Python으로 가져다 쓸 수 없을까?

고대 자료 발굴

일단 가장 먼저 해본것은 구글에 "알송 가사 API" 라고 검색한 것입니다. 그러자 검색결과가 나오는데, 엄청 옛날의 포스팅만 나왔습니다. 가장 최근 자료가 2011년 6월이니 막혀있어도 이상하지 않다고 생각했죠. 그래도 밑져야 본전이라고 생각해서 옛날 포스팅들을 참조해서 API 구조를 추정했습니다. 그렇게 다음과 같은 가정을 얻었습니다.

  1. 알송은 하위호환을 위해 가사 API를 그리 크게 바꾸지 않았을 것이다.
  2. 가사 검색 API(이하 API)는 SOAP 기반으로 동작한다. 하지만 wsdl 파일이 제거되어 있다.
  3. 검색 방식은 크게 두 가지, 하나는 titleartist_name기반의 검색, 또 하나는 음원파일 일부의 md5 해시값을 이용한 검색이다.
  4. SOAP 기반이므로 요청과 반환값은 모두 XML 포멧일 것이다.
  5. 가사의 각 소절 sync 표현식은 \[(\d{2,}):(\d{2}\.\d{2})\].+?<br> 형식으로 구성된다.

요청부터 날려보자

밑져야 본전이라고 생각해서 요청부터 날려보기로 했습니다. API 호출 방식은 분명히 SOAP이지만 wsdl도 없는데 잘 될리가 없어보이기도 하고, 검색해서 찾은 자료 모두가 SOAP client를 사용하지 않았으므로 그냥 raw string 다루듯 접근했습니다.

import requests

TEMPLATE = """\
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope
xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope"
xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:ns2="ALSongWebServer/Service1Soap"
xmlns:ns1="ALSongWebServer"
xmlns:ns3="ALSongWebServer/Service1Soap12">
<SOAP-ENV:Body><ns1:GetResembleLyric2>
<ns1:stQuery>
<ns1:strTitle>{title}</ns1:strTitle>
<ns1:strArtistName>{artist_name}</ns1:strArtistName>
<ns1:nCurPage>{page}</ns1:nCurPage>
</ns1:stQuery>
</ns1:GetResembleLyric2>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
"""
url = 'http://lyrics.alsong.co.kr/alsongwebservice/service1.asmx'

resp = requests.post(
    url,
    data=TEMPLATE.format(
        title='Catch The Moment',
        artist_name='LiSA',
        page=0,
    ).encode(),
    headers={
        'content-type': 'application/soap+xml',
    },
)

print(resp.text)

실행해보니까 정말로 동작합니다! 이제 써먹을 수 있게 가공해야겠다고 생각했습니다.

예쁘게 만들기

XML 결과상태로 그대로 쓰는건 불가능하기에 적절히 가공하기로 했습니다. 편의상 노래 한 곡을 Song이라고 정의하고, 각각의 검색 결과를 Record, 그 안에 포함된 가사를 Lyric 라고 명명하기로 했습니다.

일단 완성된 코드를 붙여넣자면 이러합니다.

import re
from collections import defaultdict
from datetime import timedelta
from typing import Dict, List, Union

import attr

import inflection

from lxml.etree import fromstring

import requests


timing_pattern = re.compile(r'\[(\d+):(\d+\.\d+)\](.*)')

def get_tag_name(name: str) -> str:
    return inflection.underscore(
        name.replace('{ALSongWebServer}', ''),
    ).replace('str_', '')


class Lyric:

    def __init__(self, text: str) -> None:

        self.data: Dict[timedelta, List[str]] = defaultdict(list)

        chunks = text.split('<br>')
        for chunk in chunks:
            match = timing_pattern.match(chunk)
            if match:
                minutes = int(match.group(1))
                seconds = float(match.group(2))
                pos = timedelta(minutes=minutes, seconds=seconds)
                self.data[pos].append(match.group(3).strip())


@attr.dataclass(slots=True)
class Record:

    title: str
    artist_name: str
    album_name: str
    register_first_name: str
    register_name: str
    info_id: str = attr.ib(repr=False)
    only_lyric_word: str = attr.ib(repr=False)
    register_first_e_mail: str = attr.ib(repr=False)
    register_first_url: str = attr.ib(repr=False)
    register_first_phone: str = attr.ib(repr=False)
    register_first_comment: str = attr.ib(repr=False)
    register_e_mail: str = attr.ib(repr=False)
    register_url: str = attr.ib(repr=False)
    register_phone: str = attr.ib(repr=False)
    register_comment: str = attr.ib(repr=False)
    lyric: Union[str, Lyric] = attr.ib(converter=Lyric, repr=False)


def make_record(el) -> Record:
    kw = {get_tag_name(e.tag): e.text for e in el}
    return Record(**kw)


TEMPLATE = """\
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope"
xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:ns2="ALSongWebServer/Service1Soap" xmlns:ns1="ALSongWebServer"
xmlns:ns3="ALSongWebServer/Service1Soap12">
<SOAP-ENV:Body>
<ns1:GetResembleLyric2>
<ns1:stQuery>
<ns1:strTitle>{title}</ns1:strTitle>
<ns1:strArtistName>{artist}</ns1:strArtistName>
<ns1:nCurPage>{page}</ns1:nCurPage>
</ns1:stQuery>
</ns1:GetResembleLyric2>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>
"""

class Song:

    def __init__(self, records: List[Record]) -> None:
        self.records = records

    @classmethod
    def from_title_and_artist(cls, title: str, artist: str):
        resp = requests.post(
            'http://lyrics.alsong.co.kr/alsongwebservice/service1.asmx',
            data=TEMPLATE.format(
                title=title,
                artist=artist,
                page=0,
            ).encode(),
            headers={'Content-Type': 'application/soap+xml'},
        )
        h = fromstring(resp.content)
        return cls([make_record(el) for el in h[0][0][0]])


song = Song.from_title_and_artist('Catch the moment', 'LiSA')
print(song.records[0])

참고로 실제 실행해보려면 외부 의존성 attrs, inflection, lxml, requests 를 설치하셔야 합니다.

  1. Song 개체는 classmethod로 생성합니다. 사실 mp3을 기준으로 검색하는 버전도 만들어보고 싶었는데 귀찮아져서(...) 현재 구조가 되었습니다.
  2. 검색결과는 lxml을 이용하여 파싱합니다. XML값을 처리하는 도구중에 가장 손에 익은것이 lxml이었기 때문에 채택했습니다.
  3. make_record 함수를 만들었습니다. 해당 함수에 넣으면 camelCase인 tag name을 underscore로 전환하고, 적절히 가공하여 Record class의 생성자 재료로 변환하여 줍니다.
  4. Record class는 attrs 패키지를 이용해서 구현했습니다. 그냥 만들려고 했다간 생성자가 너무 복잡해지기 때문입니다. Python 내장의 dataclasses를 쓴다는 선택지도 있었지만 slot 기능을 지원하는 attrs를 골랐습니다.
  5. 가장 중요한 가사는 <br> 기준으로 줄을 나눈 다음, 시간 마커와 가사를 분리해서 저장했습니다. 가령 같은 시간에 3개의 가사가 동시에 나오는 경우 같은 시간 마커를 가지는 line이 3개가 됩니다. 이를 유연하게 저장하기 위해 시간마커를 key로 하는 listdict를 만들었습니다. 몇 줄이 와도 자동으로 처리가 되도록 만들고 싶었습니다.

주물주물 1 - 싱크 손상 감지

알송에서 가사를 보다보면 가끔씩 가사가 출력되다가 노래는 아직 끝나지 않았는데 가사만 혼자 끝으로 날아가는 경우가 있습니다. 이런 상태를 감지하는 기능을 만들어보기로 했습니다.

import warnings


class Lyric:

    def __init__(self, text: str) -> None:

        self.data: Dict[timedelta, List[str]] = defaultdict(list)
        last_pos = timedelta(seconds=0)

        chunks = text.split('<br>')
        for chunk in chunks:
            match = timing_pattern.match(chunk)
            if match:
                minutes = int(match.group(1))
                seconds = float(match.group(2))
                pos = timedelta(minutes=minutes, seconds=seconds)
                if pos >= last_pos:
                    last_pos = pos
                    self.data[pos].append(match.group(3).strip())
                else:
                    warnings.warn(
                        '손상된 Sync가 있어 무시하고 진행합니다.',
                        RuntimeWarning,
                    )

실제로 Song.from_title_and_artist('それは小さな光のような', 'さユり')를 실행하면 해당 Warning이 나오는 것을 확인할 수 있습니다.

주물주물 2 - smi 자막으로 저장하기

해당 가사를 다른 미디어 재생기에서 보기 위해서는 smi 자막으로 전환할 필요가 있습니다. smi 포멧 또한 XML 포멧이긴 하지만, 너무나도 변칙적인 요소가 많은 관계로 정석적인 XML 생성보다는 그냥 문자열 생성이 낫다고 판단하여 작업해보았습니다.

class Lyric:

    ...  # 생략

    def to_sami(self) -> str:
        timeline = sorted(self.data.keys())
        return '\n'.join(
            '<SYNC Start={}><P Class=KRCC>{}'.format(
                int(pos.total_seconds() * 1000),
                '<br>'.join(self.data[pos])
            )
            for pos in timeline
        )


class Record:

    ...  # 생략

    def save_as_sami(self, filename: str):
        with open(filename, 'w') as f:
            f.write(self.to_sami())

    def to_sami(self) -> str:
        return f"""\
<HEAD>
<TITLE>{self.artist_name} - {self.title}</TITLE>
""" + """\
<STYLE TYPE="text/css">
<!--
P {margin-left:8pt; margin-right:8pt; margin-bottom:2pt; margin-top:2pt;
  text-align:center; font-size:20pt; font-family:sans-serif;
  font-weight:normal; color:White;}
.KRCC {Name:한국어; lang:kr-KR; SAMIType:CC;}
-->
</STYLE>
</HEAD>
<BODY>
<!--
""" + f"""\
아티스트: {self.artist_name}
제목: {self.title}
앨범: {self.album_name}
-->
""" + self.lyric.to_sami() + """
</BODY>
</SAMI>
"""

...  # 생략

song = Song.from_title_and_artist('それは小さな光のような', 'さユり')
song.records[0].save_as_sami('test.smi')

실제로 test.smi파일이 생성됨을 확인할 수 있습니다.

더 해보고 싶었지만 하지 않은것

1. MP3 파일을 기준으로 가사 검색하기

곡의 제목이나 아티스트를 제대로 알 수 없는 경우, MP3 기준으로 가사를 검색하려면 해당 파일을 열어서 ID3 부분을 모두 무시하고 실제 음원 부분에서 163840 바이트를 읽어서 md5로 hexdigest하면 됩니다.

<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope
xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope"
xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:ns2="ALSongWebServer/Service1Soap"
xmlns:ns1="ALSongWebServer"
xmlns:ns3="ALSongWebServer/Service1Soap12">
<SOAP-ENV:Body>
<ns1:GetLyric5>
<ns1:stQuery>
<ns1:strChecksum>{hash}</ns1:strChecksum>
<ns1:strVersion>2.0 beta2</ns1:strVersion>
<ns1:strMACAddress>ffffffffffff</ns1:strMACAddress>
<ns1:strIPAddress>255.255.255.0</ns1:strIPAddress>
</ns1:stQuery>
</ns1:GetLyric5>
</SOAP-ENV:Body>
</SOAP-ENV:Envelope>

하지만 Python으로 하기에 그리 적합한 작업이라고 생각되지 않는데다가 이상하게 전송하면 서버로부터 차단당할 위험이 있어보이는 필드들(version, mac address, ip address)도 섞여있어서 실제로 손대진 않았습니다.

2. SMI 파일을 알송 자막 포멧으로 바꾸기

실천하지 않은 이유는 간단합니다. SMI 파싱하기 싫어요.

SMI는 심하게 변칙적이라서 파싱이 쉽지 않습니다. XML의 양식을 띄고 있음에도 XML 헤더가 없다던가, HTML의 CSS같은 요소가 포함되어 있지만 CSS 표준과는 부합하지 않는다던가 하는 문제들이 산적해 있습니다. 그걸 파싱하겠다고 덤비기는 귀찮아서 손대지 않았습니다.

참고자료

참고: 모두 오래된 자료들입니다.