서론
예전에 재미있게 즐긴 퍼즐 게임 중에 I Love Hue라는 것이 있다. 평면에 서로 다른 색의 타일들이 배치되어 있고, 몇몇 개는 고정되어 있다. 이 고정된 타일들을 힌트 삼아, 나머지 타일들이 자연스러운 그라데이션을 이루도록 타일들을 재배치하는 것이 목적인 게임이다.
그런데 최근에 절대음감 테스트를 재미삼아 해보다가 생각이 났다. 이걸 색 대신 음으로 바꾼 버전을 한 번 만들어보는 건 어떨까? 최근 AI의 성능이 출중하기도 하고, 가볍게 할 토이 프로젝트도 필요했기 때문에 바로 시작하기로 했다.
규칙은 간단하다. 서로 다른 음을 내는 타일들을 N개 준비하고, 그 중 몇 개는 고정시킨다. 고정되지 않은 나머지 타일들을 재배치해서 음이 오름차순 혹은 내림차순으로 정렬되도록 만들면 성공인 게임. 원본이랑 거의 똑같게 만들 수 있으므로 복잡한 기획은 필요하지 않다. 와!
기술
우선 어떤 기술을 쓸지부터 정해야 한다. 가볍게 웹 앱을 만드는 것이 가장 편리하지만 네이티브 앱을 내고 싶은 욕심도 있어 크로스플랫폼 프레임워크를 고르기로 했다. React Native와 Flutter 중에 예전에 써봤던 Flutter를 골랐는데.. 사실 귀찮은 코딩은 대부분 AI한테 시키고 설계 위주로 고민할 것이었어서, React Native를 쓰는 것도 좋았을 것 같다는 생각이 든다. 하지만 이 글을 쓰는 시점에서 이미 물 건너간 이야기이다.
아무튼 Flutter를 사용하기로 했는데, vanilla Flutter는 상태 관리가 귀찮은 프레임워크라 상태 관리 패키지를 하나 골라야 했다. 저번에도 GetX를 썼기에 이번에도 관성적으로 골랐지만 아까와 같은 이유로 Riverpod를 쓰면 어땠을까 하는 생각이 든다.. 흐
간단한 퍼즐 게임이라 앱의 구성은 매우매우 간단하다. 타이틀, 레벨 선택, 레벨 플레이 화면 정도만 두면 된다. 가볍게 명세를 작성해 넘겨주고, 디렉터리 구조를 Model - (Controller + Bindings / View) 로 나누도록 했다.
배포는 GitHub Pages로 하려고 하는데, 백엔드에서 처리할 것이 정적 파일 제공밖에 없기 때문이다. 만약 계정 시스템 등을 추가한다면 무료 배포 서비스로 옮겨야겠지만 아직은 계획이 없다.
오디오 처리
색과 달리 소리는 용량이 크다. 색은 고작 32비트 정수 하나로도 표현이 가능하지만.. 소리는 다르다. 소리는 복잡하다. 원본 게임은 타일의 색만 지정해주면 되는 구조기 때문에 용량 문제가 딱히 없지만, 내가 만드려는 것은 무거운 오디오 파일을 써야 하는 문제가 있다.
사인파와 같이 극도로 간단한 소리는 Frequency, Length 의 두 값만 있어도 처리가 가능하다. 그러나 인터넷에서 볼 수 있는 절대음감 테스트 게임에서도 사인파같은 단순한 소리는 안 쓴다. 피아노 소리같은 듣기 좋은 소리를 두고 저걸 쓸 이유는 없으니까. 피아노 소리는 흔히 들을 수 있는 소리지만 간단한 식으로 표현하기 매우 어려운 형태이다. 뭐.. 푸리에 변환을 쓴다던지 해서 피아노의 음색을 여러 주기함수의 합으로 표현할 수는 있겠지만, 너무 복잡하다.
결국 음원을 준비하는 것이 가장 쉽다는 결론에 도달했다. 어차피 타일을 눌렀을 때 나는 소리는 1~2초 사이의 짧은 음일 것이기에 용량이 그리 크지는 않다. 마침 작업용으로 구매한 FL Studio도 있으니 이걸로 음원을 만들면 된다. 최대한 용량을 줄이기 위해 mono에 44.1KHz라는 최소한의 품질로 만들었다.
하지만.. 용량이 여전히 너무 크다! 이렇게 만들어도 하나에 173KB 정도 된다. 피아노는 대충 70개 정도의 건반이 주로 사용되고, 이 건반에 해당하는 음은 전부 게임에서 쓰고 싶었기 때문에 173 * 70 = 대충 12MB이다. 별로 안 크다고 느껴질 수 있지만 웹 페이지의 크기는 약 2 ~ 3 MB 정도가 일반적 으로 보이기에 더 줄여야 했다. 심지어 피아노 악기만 쓸 게 아니라면(기타, 사인파 등)? 만약 70개 건반에 정확히 해당하는 음이 아니라 건반과 건반 사이의 애매한 중간 음이라면(예를 들어 E와 F 사이의 애매한 음)? 용량이 더욱 늘어난다.
용량을 줄여보자
사실 크로스플랫폼 앱을 만드는 상황이므로, 네이티브 앱의 경우는 용량 문제가 엄청 크게 다가오지 않는다. 첫 다운로드만 잘 하면 되니까.. 그러나 내가 목표로 하는 주 플랫폼은 웹이다. 웹사이트는 매번 로딩을 하기 때문에 용량이 큰 웹 앱을 만들면 안된다. 그러므로 최대한 로딩을 줄여봐야 한다.
다행스럽게도 웹 앱의 경우 Flutter asset에 오디오 파일을 추가해도, 첫 페이지 로딩 시에 바로 다운로드하는 구조는 아니었다. rootBundle.load()를 호출할 때 필요한 asset을 HTTP로 가져오는 방식이었다. 이러면 HTTP 요청의 수를 최대한 줄임으로써 해결할 수 있으니, LRU 캐시를 구현해 오디오를 메모리에 두고 쓰기로 했다. 레벨이 로딩될 때 레벨에서 사용할 오디오가 어떤 것들이 있는지 미리 파악한 뒤, 캐시 미스가 나는 것들만 불러오는 것이다. (글 쓰면서 생각해보니 캐시 구현에 오류가 있는 것 같다. 글을 쓰고 고쳐야겠다.)
그리고 여기서 한 번 더 줄일 수 있다. 70개 건반의 음이 모두 필요하다고 해서 70개를 전부 다 음원으로 나눌 필요는 없으므로, 옥타브 당 3개의 파일을 마련한 뒤 음원을 조작해서 음 높이만 바꿔준다면 용량이 1/4로 줄어든다.
이제 여기서 음 높이를 바꾸는 방법에 대해 고민해보자. 두 가지 방법이 있다.
1. 음의 재생 속도를 바꾸는 방법. 주파수가 자연스럽게 바뀌므로 알아서 높이가 바뀐다.
2. PSOLA 등의 음 높이 변환 알고리즘 사용. 음 길이를 유지할 수 있다.
원래는 음 길이를 유지하고 싶어 PSOLA 라이브러리를 써 보았는데 파라미터를 아무리 조절해도 오디오 품질이 구려져서 쓸 수가 없었다. 인위적으로 조작하는 방식이라 어쩔 수 없나보다. 그래서 음 길이가 바뀌더라도 재생 속도를 바꾸는 방법을 쓰기로 했다. 어차피 옥타브 당 3개의 파일을 쓰기 때문에 음 길이가 크게 바뀌지도 않는다.
이 이후로는 간단한 LRU 캐시 구현, JSON으로 레벨 데이터 생성 등이 있었다. 오디오 파일같은 원시 데이터를 다룰 일이 좀처럼 없었는데 오랜만에 다루니 상당히 재미있었고, PSOLA/WSOLA 같은 음 높이 변환 알고리즘에 대해서도 공부할 수 있었다. AI를 굴려서 코드를 쉽게 뽑아낼 수 있으니 모르는 것을 공부하는 것도 더 쉬워진 것 같다. 또 재미있는 소재가 생각나면 구현해야지.
