노래 맞히기 게임 제작기

본 포스트에서는 봇의 작동 방법을 설명하지 않습니다. 게임을 어떻게 만들었는지에 대해서 설명합니다.

카카오톡으로 노래 맞히기 게임을 만들어보았다

스타크래프트1 유즈맵중에 노래를 듣고 제목을 맞히는 게임이 있었다. 그냥 노래 제목을 입력하면 되는 게임인데 상당히 재밌었다.

그러다가 이 게임을 스타크래프트가 아닌 더 접근성이 좋은 환경에서 플레이하면 재미있겠다라는 생각이 들었다

내가 선택한 플랫폼은 바로 카카오톡! 그것도 플러스 채널이 아닌 일반 단톡방에서 친구들과 채팅하면서 말이다

노래 데이터베이스 구축

사실 노가다가 요구될 수 밖에 없는 부분인데 내가 혼자서 다 추가하기도 힘들고 다른사람의 도움을 받아야 했다

그렇다고 링크 주시면 제가 추가할게요~라고 하기에는 사실상 내가 다 추가하는게 아닌가?

진정한 텐-엑스 디벨로퍼라면 이런 과정까지도 자동화를 시켜야 한다

구글 스프레드 시트로 형식을 만든 후 다른 사람들에게 협조를 요청했다

유튜브 링크를 받는게 최선이지만 내가 영상을 전부 확인 해 볼 수 없다

이상한 영상이거나, 앞뒤로 채널 소개나 다른 영상등 노래와 관계없는 부분이 있기도 하고 똑같은 노래가 중복되었을 경우 알아채기 힘들기 때문이다

다행히도 애니송에 대한 DB 비슷한 사이트가 있어서 이쪽 링크를 우선해서 올려달라고 부탁했다

여기에 있는 영상은 검증되었고 불필요한 부분도 없고 정리도 잘 되어있어서 내 노래 데이터베이스의 희생양이 되기에 안성맞춤이였다

노래 파일 다운, 정보 저장

위의 스프레드 시트의 노래를 다운받는 프로그램은 파이썬으로 작성했다

import requests
import hashlib
import json

from handle_file import has_mp3, download_video, convert_video_to_mp3, delete_video, save_json

# 1. Download video
# 2. Convert video to mp3
# 3. Generate json file

SHEET_URL = 'https://docs.google.com/spreadsheets/d/어쩌구저쩌구/export?format=tsv'
anisongs = []

# Download google sheet
anime_database = requests.get(SHEET_URL).content.decode('utf-8').split('\n')

for line in list(anime_database)[1:]:
    print(line)
    try:
        name, url, alt_names, *_ = line.split('\t')
        url_hash = hashlib.sha256(url.encode()).hexdigest()
        alt_names = [n.strip() for n in alt_names.split(',')]
        alt_names = list(filter(lambda x: x != '', alt_names))
        print(url_hash)
        anisong = {
            "name": name,
            "alt_names": alt_names,
            "url_hash": url_hash
        }

        if not has_mp3(url_hash):
            download_video(url, url_hash)
            convert_video_to_mp3(url_hash)
            delete_video(url_hash)
    except Exception as e:
        print(f'ERROR ON {line}', e)
        continue

    # Add to anisong only when mp3 exist
    anisongs.append(anisong)

save_json(anisongs)

놀랍게도 스프레드 시트 파일은 https://docs.google.com/spreadsheets/d/어쩌구저쩌구/export?format=tsv을 HTTP로 요청하는 것 만으로 다운로드가 가능했고 형식까지 지정 가능했다

형식은 tsv파일로 받아오고 있는데 내가 노래 별명을 ,로 구분해서 써달라고 했기 때문에 csv를 사용할 수 없었다 (오 나의 실수)

영상 다운은 requestspytube를 사용했고 ffmpeg를 사용해서 영상에서 노래만 추출했다

또한 중복 다운로드를 막기위해 URL 문자열을 해싱해서 해당 파일 이름으로 사용했고 해당 파일을 이미 다운로드 받았으면 다운로드는 스킵한다

URL도 나름 쓸 수 있는 문제가 제한되어있어서 그대로 파일명으로 사용해도 괜찮을까 싶지만 역시나 / 문자가 문제다

파일 이름에 적지 못 하는 문자는 변경하거나 아니면 파일 이름을 랜덤으로 하거나 1부터 올라가거나 하는 여러가지 방법이 있지만 역시 해시가 장점이 많은 것 같다

이름이 길어지고 이름만 봐서는 내용을 추측할 수 없다는게 단점인데 충돌도 (거의)안 나고 통째로 서버를 옮길때도 유용하다

노래에 대한 정보는 json으로 만들어서 저장했다. 이런 데이터를 어떤 형식으로 저장해야 할 지 고민될 때는 json으로 하면 중간은 가는 것 같다

또한 노래를 다운받는 과정에서 쓰레드를 활용하지 않았는데 어짜피 여러 쓰레드로 다운받으면 사이트에서 나를 블록하기 때문이다

한 번만 다운로드 받는거고 다운 속도도 중요하지 않은데 느리게 받으면 안타까울게 있나?

게임 알고리즘 작성

게임 알고리즘은 자바스크립트로 작성했다

간단하게 정답을 입력하면 끝나는 게임이지만 코드 몇 줄로 끝나는 간단한 알고리즘은 아니였다.

처음에는 노래만 들려주고, 조금 있다가 OOO와 같이 노래 이름 길이만 알려주고, 마지막으로는 초성만 알려주는 식으로 진행한다

이 간단한 게임에 숨겨진 규칙들을 정리하면 다음과 같다

  • 단톡방 별로 게임이 분리되어야 한다(단톡방 별로 게임이 진행되게 해야 한다)
  • 게임을 시작했으면 게임이 끝나기 전까지 게임을 새로 시작할 수 없다
  • 정답을 맞히면 노래 이름 길이, 초성을 알려주는 이벤트는 없어져야 한다

따라서 다음과 같이 코드가 굉장히 길어지게 되었다

  • index.js
import { selectOneFromArray } from '@util/random'
import { normalizeString, makeHangulChosung } from '@util/processHangul'
import { makeTimeouts, deleteTimeouts } from './timeout'
import sliceMusic from './sliceMusic'
import { sendMP3Stream } from '@send/mp3'
import { proceedScoreStage } from './score'

import path from 'path'
import fs from 'fs'

const ANISONG_PATH = path.join(process.cwd(), 'anisonggame')
const ANISONG_DATA_FILE_PATH = path.join(ANISONG_PATH, 'anisongs.json')
const ANISONG_MP3_FOLDER_PATH = path.join(ANISONG_PATH, 'music')

const Game = new Map()
let ANISONGS

const readAnisongDB = () => {
    ANISONGS = JSON.parse(fs.readFileSync(ANISONG_DATA_FILE_PATH))
}

const anisonggame = ({ data, channel }) => {
    if (!ANISONGS) return

    const channelID = channel.channelId.toInt()

    if ((data.text === '라라 애니송' || data.text === 'ㄹㄹ ㅇㄴㅅ') && !Game.has(channelID)) {
        gameStart(channel)
        return
    }

    if (Game.has(channelID)) {
        userAnswer(data, channel)
        return
    }
}

const gameStart = async (channel) => {
    const anisong = selectOneFromArray(ANISONGS)
    // console.log(anisong.name)
    const game = {
        anisong,
        timeouts: makeTimeouts(channel, anisong, Game),
        chosungRevealed: false
    }

    Game.set(channel.channelId.toInt(), game)
    const musicPath = path.join(ANISONG_MP3_FOLDER_PATH, `${anisong.url_hash}.mp3`)

    try {
        channel.sendChat('애니송 맞히기 게임 시작!\n(모바일은 바로 재생 가능!)')
        const stream = await sliceMusic(musicPath)
        sendMP3Stream(channel, stream)
    } catch (err) {
        channel.sendChat('노래 파일 읽는 중 에러')
        console.error('stream read async error')
    }
    
}

const isCorrectAnswer = (userAnswer, answers) => {
    return answers.some((answer) => normalizeString(userAnswer) === normalizeString(answer))
}

const userAnswer = (data, channel) => {
    const channelID = channel.channelId.toInt()
    const game = Game.get(channelID)
    const answers = [game.anisong.name, ...game.anisong.alt_names]
    const chosungAnswers = answers.map((answer) => makeHangulChosung(answer))

    // Correct
    if (isCorrectAnswer(data.text, answers) || (game.chosungRevealed === false && isCorrectAnswer(data.text, chosungAnswers))) {
        deleteTimeouts(game.timeouts)

        const sender = data.getSenderInfo(channel)
        channel.sendChat(`${sender.nickname}님이 정답을 맞히셨습니다!\n${game.anisong.name}`)
        proceedScoreStage(channel, sender)

        Game.delete(channelID)
    }
}

readAnisongDB()

export { anisonggame, readAnisongDB }

가장 불편했던 점은 파일을 불러올 때 위치였는데 path.join(process.cwd(), 'anisonggame')를 사용해야 할 정도로 뭔가 통일되어있지 않다. (이건 파이썬도 마찬가지)

또 실행을 어느 위치에서 하느냐에 따라 바뀌어서 아주 골치아픈 문제다

하지만 자바스크립트만의 장점도 있었는데, 정답인지 체크할 때 정답인 문자열이 많이 있어서 정답들을 순회해야 하는데 다음과 같이 작성 가능하다

const isCorrectAnswer = (userAnswer, answers) => {
    return answers.some((answer) => normalizeString(userAnswer) === normalizeString(answer))
}

Array.some을 사용하면 조건이 충족되었을 경우 바로 빠져나와서 boolean을 반환하기 때문에 알고리즘에 사용하기 딱 좋다

여기서 아 그냥 map으로 normalizeString 시켜버린 다음에 includes 쓰면 되잖아요! 라고 할 수 있겠는데 님 말이 맞다.

하지만 위의 코드에서 data.text === '라라 애니송'와 같은 코드를 보며 으으... 왜 명령어를 상수로 선언해서 사용 안 함? 같은 생각이 든다면 알아야 할 게 있다

이건 작은 개인 프로젝트다. 진짜 제대로 하려 한다면 명령어들만 모아서 맵 파일을 따로 만들거나 라라 라는 prefix를 구현하거나 그래야 하지만 이런 작은 게임을 만드는데 정말로 필요할까?

  • 헬로우월드도 아래와 같이 짤 수 있다

이 밑의 코드들도 대충짜거나 잘못짜거나 한 점이 보일테지만 앞으로 관리도 안 할 코드라서 그런것이니 넘어가 주시길 바란다

문자열 처리

힌트를 주기 위해 글자의 초성만 알려줘야 하는데 자바스크립트에는 한글 처리 기능이 없다

한글만 마스킹 처리하거나 한글만 초성으로 바꿔주는 함수를 작성했다

  • processHangul.js
const SYMBOL = '◯'
const CHOSUNG = [ 'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ' ]

const isHangulChar = (char) => {
    // Code from zetawiki
    const charCode = char.charCodeAt(0);
    if( 0x1100<=charCode && charCode<=0x11FF ) return true;
    if( 0x3130<=charCode && charCode<=0x318F ) return true;
    if( 0xAC00<=charCode && charCode<=0xD7A3 ) return true;
    return false;
}

const removeSpace = (str) => {
    return str.replace(/ /g, '')
}

const normalizeString = (str) => {
    return removeSpace(str).toLowerCase()
}

const makeHangulSymbol = (str) => {
    return str.split('').map((char) => isHangulChar(char) ? SYMBOL : char).join('')
}

const makeHangulChosung = (str) => {
    return str.split('').map((char) => {
        // Not Korean
        if (!isHangulChar(char))
            return char

        // Already Chosung
        if (CHOSUNG.includes(char))
            return char

        const charCodeModified = char.charCodeAt(0) - 44032
        return CHOSUNG[Math.floor(charCodeModified / 28 / 21)]
    })
    .join('')
}

export { removeSpace, makeHangulSymbol, makeHangulChosung, normalizeString }

이런 코드들을 작성하면서 생각하는게 있는데 역시 라이브러리 사용보다 직접 짜는게 나은 것 같다

npm i만 쳤다 하면 주르륵 뜨는 vulnerabilities에 나는 지쳐버렸다

라이브러리를 쓴다고 해도 이 라이브러리에 유해한 코드는 없는지, 업데이트는 잘 되는지를 알아야 하고 사용법도 익혀야 한다

또한 내가 쓰려는 기능보다 훨씬 많은 기능을 제공하는게 보통이라 좋은 일이 아니다

반대로 라이브러리를 꼭 써야 할 때가 있는데, 보안과 관련해서는 꼭 사용하려고 한다

모든 코드는 취약점이 생길 수 있고, 이를 예방하려면 많은 사람들의 관찰이 필요하기 때문이다

파일 가공과 전송

노래를 처음부터 들려주면 재미가 없다. 랜덤 구간에서 시작해서 조금만 들려주기를 원한다

게다가 전송을 하기 위해 파일로 가공을 하는게 아니라 버퍼에 집어넣어야 한다

솔직히 자바스크립트로는 좀 어려운 부분이지만 어떻게든 해냈다

  • sliceMusic.js
import ffmpeg from 'fluent-ffmpeg'
import streamBuffers from 'stream-buffers'
import { selectFloat } from '@util/random'

const MUSIC_TIME = 40

const getStartTime = (musicPath) => new Promise((resolve, reject) => {
    ffmpeg.ffprobe(musicPath, (err, metadata) => {
        // Get duration
        const duration = metadata.format.duration

        // Randomly select start time
        const startTime = selectFloat(0, duration - MUSIC_TIME)
        resolve(startTime)
    });
})

const sliceMusic = (musicPath) => new Promise((resolve, reject) => {
    const stream = new streamBuffers.WritableStreamBuffer({
        initialSize: (100 * 1024),   // start at 100 kilobytes.
        incrementAmount: (10 * 1024) // grow by 10 kilobytes each time buffer overflows.
    })
    console.log('slice music', musicPath)

    getStartTime(musicPath).then((startTime) => {
        ffmpeg(musicPath)
        .setStartTime(startTime)
        .duration(MUSIC_TIME)
        .toFormat('mp3')
        .on('end', () => {
            resolve(stream)
        })
        .output(stream)
        .run()
    })
    
})

export default sliceMusic

먼저 해당 노래 파일을 불러와서 노래의 길이를 알아낸다

그 다음 0초에서 (노래 전체 길이 - 들려줄 노래 길이)초까지 중에서 랜덤으로 시작지점을 선택 후 ffmpeg를 사용해서 노래를 자른다

노래는 파일로 저장하는게 아니라 스트림 버퍼로 들어간다. IO를 거치지 않고 바로 보낼 수 있는 것이다

랜덤하게 하나 선택

노래 목록중에서 하나를 고르거나 노래 구간을 선택할 때 랜덤으로 선택해야 하는 부분이 있는데 이를 위해 구현했다

  • random.js
const selectOneFromArray = (array) => {
    return array[Math.floor(Math.random() * array.length)]
}

const selectFloat = (start, end, fix=1) => {
    return (Math.random()*(start - end) + end).toFixed(fix)
}

export { selectOneFromArray, selectFloat }

놀랍게도 위와 같이 작성하면 고르게 선택되지 않는다고 한다!

하지만 내가 알 바 아니다.

나쁜건 자바스크립트다. 랜덤에 관련된 함수가 없는게 나쁜거다.

자동 데이터베이스 업데이트

구글 스프레드 시트로 데이터를 모은 장점을 최대한 살려보고자 자동 노래 목록 업데이트 기능을 만들었다

  • schedule.js
import schedule from 'node-schedule'
import { spawn } from 'child_process'
import { readAnisongDB } from '@rules/anisonggame'

// Update anisong file everyday on 4:00
schedule.scheduleJob({ hour: 4, minute: 10 }, () => {
    console.log('Update anisong files');
    console.log(new Date())
    const python = spawn('python3', ['anisonggame/main.py'])
})

// Update anisong DB everyday on 5:00
schedule.scheduleJob({ hour: 5, minute: 10 }, () => {
    console.log('Update anisongdb')
    console.log(new Date())
    readAnisongDB()
})

console.log('schedule imported')

node-schedule 라이브러리를 사용해서 매일 새벽에 파이썬을 실행해서 업데이트 하도록 했다

anisonggame에서 export 하는 readAnisongDB을 받아서 실행시키는데 완전 안티 패턴이다

그리고 누군가가 스프레드 시트에 테러를 해 놓는다면 다음날 갑자기 작동이 안 될텐데, 해당 문제는 다른 방법으로 방지해놨다

설정과 배포

봇이 하루종일 내 관리 없이도 잘 돌아가야 하길 원했고 돈도 없어서 직접 서버를 구축했다

요즘은 소형PC가 잘 나와서 컴퓨터 크기가 아주 작은데, 이게 인터넷 선이 있는 배전반에 쏙 들어간다

먼저 소형PC에 서버 설치 후 SSH를 설정해놓으면 배전반에 넣어두고 계속 굴리면 된다!

이런 방법으로 153일째 업타임을 기록하고 있다

webpack

처음에는 서버에 웹팩을 사용하는게 좀 이상하게 느껴졌는데 처음 설정의 난해함을 제외하면 사용하는 편이 이득이 많다

요즘은 또 자바스크립트 라이브러리를 불러오는 방법이 바뀌는 과도기이기도 하고 alias 설정 같은 장점이 있다

alias

사실 이게 웹팩을 쓴 가장 큰 이유인데, 자바스크립트에서 다른 자바스크립트 파일을 불러올 때 @로 쉽게 위치 설정을 할 수 있다

import { selectFloat } from '@util/random'

특히 어느 depth에서 사용할지 모르는 유틸 파일은 이 기능이 없으면 좀 난감하다

node.js는 반드시 이 기능을 기본으로 넣어줘야 한다

배포

배포라면 역시 pm2! 근데 왜 쓰는지는 모른다.

스케일에 좋다고 하는데 내 프로그램은 스케일을 할 필요도 없고 할 수도 없다

그렇지만 forever를 쓰는 것 보다 전문가 티가 나서?

결론

굉장히 특이한 프로젝트고 내 맘대로 작성해봤다

버퍼 사용같은 도전도 해봤고 경로 설정 같은거와 약간 친숙해 진 느낌도 든다

어떻게 만들었는지 신기해하는 사람도 있는 반면, 이건 쉽게 만들겠는데 라고 생각하는 사람도 있겠다

그래서 작성 해 봤는데

어떻습니까?

좋은 웹페이지 즐겨찾기