[Roll20 API] 노래 가사 자동 재생 스크립트

 

 

 

* 재미나이를 활용한 스크립트이기 때문에 잔오류가 생길 수 있습니다. 문의가 오더라도 못 고칠 확률이 높습니다... (...)

* 예시로 사용한 노래 - natori - Absolute Zero

 

명령어

!노래시작 (노래이름)

!노래중지

 

 

1. 기본 세팅

- 본 글의 맨 마지막에 있는 API 스크립트를 넣는다.

 

기본 세팅 값

 

   

 

    panel_name: "lyric_panel", // 가사가 표시될 기준 토큰(이름 필수)

 

가사가 출력될 장소를 지정해줍니다. name을 바꾸셔도 되지만 안 바꾸시는 걸 추천합니다.

저는 투명화 파일을 활용했으나, 따로 디자인하셔도 될듯 싶습니다.

맵시트가 여러개로 나뉠 경우, 기본적으로 플레이어 전원이 있는 노란색 북마크 표시 맵에서 출력됩니다.

 

 

    main_size: 18,            // 윗줄(원문) 글자 크기
    sub_size: 24,             // 아랫줄(번역) 글자 크기
    main_offset: -15,         // 패널 기준 윗줄 높이 간격
    sub_offset: 15,           // 패널 기준 아랫줄 높이 간격

 

 

    // [기본 색상] 핸드아웃에 설정이 없을 때 사용될 색상
    default_main_color: "#AAAAAA", 
    default_sub_color: "#FFFFFF"

 

가사 스크립트를 꾸밀 수 있습니다. 이건 직접 건드려보셔서 맵시트에 맞게 디자인 하시는 걸 추천합니다.

기본색상의 경우 가사마다 바꿀 수 있으나, 지정하지 않을 경우 저 색상이 디폴트로 나옵니다.

가사별 색상 변동은 핸드아웃 세팅에 서술되어 있습니다.



    start_volume: 50,         // 재생 시작 시 볼륨 (0~100)
    stop_volume: 0,           // 종료 시 볼륨 (0으로 소리 제거)

 

    // [싱크 보정] 
    // 가사가 노래보다 느리면? 숫자를 줄이세요 (예: -2.0)
    // 가사가 노래보다 빠르면? 숫자를 늘리세요 (예: 1.0)
    sync_offset: 0.0, 
    

음량 관련입니다.

stop 볼륨은 웬만하면 수정하지 말아주세요. 음악이 중지되는 것이 아닌, 볼륨을 줄이는 방식입니다.

롤20에는 API로 노래를 정지할 경우 일시정지만 되어 처음부터 노래를 틀 수가 없습니다.

가사 스크립트를 사용하려면 수동으로 주크박스에서 전원을 꺼야 합니다.

그래서 플레이어에게 티나지 않게 볼륨만 0으로 줄이게 하고, GM님이 직접 끄는 방식을 택했습니다.

 


2. 노래 세팅

 

가사 스크립트 핸드아웃과 주크박스 음원의 네임을 통일해야 합니다.

 


3. 가사 (핸드아웃) 세팅

기본 핸드아웃 형식은 다음과 같습니다.

원문가사

번역가사

(00:01) < 해당 가사가 나타나는 타이밍. (분:초) 1초부터 등장해야 어색하지 않습니다.

다음원문가사

다음번역가사

(00:04)

<공백은 특수문자 ㅤ를 활용합니다.




(00:10) <간주일땐 공백을 활용하면 화면에 나타나지 않습니다. (그냥 스페이스면 html 태그가 나옵니다...ㅠㅠ)

다다음원문가사

다다음번역가사

(00:15)





(00:18) <간주중





(03:14) <마지막에는 공백과 함께 곡 끝나는 시간 + 1초를 해주셔야 노래도 가사도 안 멈춥니다.

 

빨간색은 설명이므로 작성하실땐 없애셔야 합니다.

엔터를 제대로 지켜주지 않으면  API가 스크립트를 제대로 인식하지 못합니다.

 

* 핸드아웃 예시

더보기

泣き声、遠く 息を合わせて、もう一度

 

울음소리, 저 멀리 호흡을 맞추고, 한번 더

 

(00:01)

 

そんな、僕らの未来を強く願う歌

 

그런 우리들의 미래를 강하게 바라는 노래

 

(00:05)

 

 

 

(00:10)

 

革命前夜、僕たちの声は

 

혁명전야, 우리들의 목소리는

 

(00:18)

 

夜明け前にかき消されていく

 

동이 트기 전에 완전히 사라져 가

 

(00:21)

 

ネガ、エゴ、嫉妬、くだらない悪意

 

네거티브, 자아, 질투, 보잘 것 없는 악의

 

(00:23)

 

それすらも飲み込んだ、スーパーヒーロー

 

그것조차도 삼켜버린, 슈퍼 히어로

 

(00:25)

 

息継ぎだって、ギリギリな僕らは

 

한숨 돌림이라도 빡빡한 우리들은

 

(00:27)

 

目と目、合わせて 合図して

 

눈과 눈을 맞추고 신호를 보내

 

(00:29)

 

声にならない声が、確かに聞こえていたんだ

 

목소리가 되지 못한 목소리가 분명히 들렸어

 

(00:31)

 

いやいや、その愛を守るために

 

아니아니, 그 사랑을 지키기 위해서

 

(00:34)

 

今、必要なのはそんな言い訳じゃないぜ

 

지금 필요한 건 그런 변명이 아니지

 

(00:36)

 

決められることのない、ありふれた未来を

 

정할 수 없는, 흔해 빠진 미래를

 

(00:40)

 

全部、燃やし尽くして 絶対零度

 

전부 태워 버려 절대영도

 

(00:44)

 

理由も体裁も関係ない

 

이유도 체재도 관계없어

 

(00:47)

 

もう、フラッシュバック&ディスコミュニケーション! 今

 

이제, 플래시백&디스커뮤니케이션! 지금

 

(00:48)

 

きっと、僕ら 不安定な延長線上

 

분명 우리들 불안정한 연장선상

 

(00:52)

 

聞こえた、いつの日のエスオーエス

 

들렸던 어느 날의 SOS

 

(00:55)

 

そう、何度だって

 

그래, 몇 번이든

 

(00:58)

 

繰り返してよベイベー、地獄のなる方へ

 

반복해 babe, 지옥이 될 쪽으로

 

(00:59)

 

 

 

(01:02)

 

全く以って、つまらない

 

정말이지, 지루해

 

(01:09)

 

錆び臭い街の匂いや喧騒に罰×10

 

녹내나는 거리의 냄새나 소음에 벌x10

 

(01:12)

 

被害者づらする善悪にとどめを刺してくれ

 

피해자인 척하는 선악에 일격을 가해 줘

 

(01:15)

 

全く以って、つまらない

 

정말이지, 지루해

 

(01:19)

 

錆び臭い街の匂いや喧騒に罰×10

 

녹내나는 거리의 냄새나 소음에 벌x10

 

(01:20)

 

被害者づらする善悪にとどめを刺して!

 

피해자인 척 하는 선악에 일격을 가해!

 

(01:23)

 

全部、燃やし尽くして 絶対零度

 

전부 태워버려 절대영도

 

(01:27)

 

奪って、奪い返す 前哨戦

 

뺏고 또 뺏는 전초전

 

(01:30)

 

もう、フラッシュバック&ディスコミュニケーション! 今

 

이제, 플래시백&디스커뮤니케이션! 지금

 

(01:32)

 

きっと、僕ら 不安定な感情線上

 

분명 우리들 불안정한 감정선상

 

(01:36)

 

途絶えた、いつの日のエスオーエス

 

끊어졌던 어느 날의 SOS

 

(01:39)

 

そう、何度だって

 

그래, 몇 번이든

 

(01:41)

 

思い出してよベイベー

 

기억해 babe

 

(01:42)

 

確かに今も、ずっと鳴り止まないで

 

분명히 지금도 계속 울림을 멈추지 않고

 

(01:43)

 

僕らを揺らした、ロックンロールのように

 

우리를 뒤흔든 로큰롤처럼

 

(01:46)

 

臆病にさえ苛立つ、僕の愚かさも

 

겁이 많음에조차 초조해하는 나의 어리석음도

 

(01:49)

 

消せない過去の痛みも

 

지울 수 없는 과거의 아픔도

 

(01:51)

 

全部を抱えて、歩いていくんだ

 

모든 것을 부둥켜 안고 걸어가는 거야

 

(01:54)

 

全部を抱えて、歩いていくんだ

 

모든 것을 부둥켜 안고 걸어가는 거야

 

(01:58)

 

 

 

(02:01)

 

全部、燃やし尽くして 絶対零度

 

전부 태워버려 절대영도

 

(02:06)

 

白黒つけようぜ、延長戦

 

흑백을 가리자고, 연장전

 

(02:09)

 

そう、何度も何度も何度も

 

그래, 몇 번이고 몇 번이고 몇 번이고

 

(02:11)

 

全部、燃やし尽くして 絶対零度

 

전부 태워버려 절대영도

 

(02:15)

 

理由も体裁も関係ない

 

이유도 체재도 관계없어

 

(02:18)

 

もう、フラッシュバック&ディスコミュニケーション! 今

 

이제, 플래시백&디스커뮤니케이션! 지금

 

(02:20)

 

ずっと、僕ら 不安定な延長線上

 

계속 우리들 불안정한 연장선상

 

(02:23)

 

聞こえた、いつの日のエスオーエス

 

들렸던 어느 날의 SOS

 

(02:26)

 

そう、何度だって

 

그래, 몇 번이든

 

(02:29)

 

この、感情がまた叫んでんだって ずっと!

 

이 감정이 또 다시 외치고 있다고 계속!

 

(02:30)

 

泣き声、遠く 息を合わせて、もう一度

 

울음소리, 저 멀리 호흡을 맞추고, 한번 더

 

(02:33)

 

奪われることのない、ありふれた未来を

 

빼앗길 일 없는, 흔해빠진 미래를

 

(02:37)

 

運命がなんだって

 

운명이 뭐라든

 

(02:42)

 

なぁ、絶望がなんだって

 

그래, 절망이 뭐라든

 

(02:44)

 

その目に映った、全部を抱えて

 

그 눈에 비친 모든 것을 부둥켜 안고

 

(02:46)

 

生きていくんだ、間違いないさ

 

살아가는 거야, 틀림없어

 

(02:48)

 

夜明けが来なくたって

 

새벽이 오지 않든

 

(02:50)

 

雨が降り止まなくたって

 

비가 그치지 않든

 

(02:52)

 

凍てつくほど 燃えている、絶対零度

 

얼어붙을 만큼 타고 있는 절대영도

 

(02:54)

 

 

(02:58)

 

 

 

(03:19)

 

 

가사별 색상을 바꾸고 싶다면 해당 핸드아웃 상단에 아래 명령어를 기입해주셔야 합니다.

 

main_color: #

sub_color: #

 

둘 중 하나만 등록하셔도 작동됩니다.


4. 주의사항

* 가사 스크립트가 끝나면 노래 소리 볼륨이 0으로 되어, 루프를 하고 싶으실 경우 수동으로 볼륨을 올려주셔야 합니다.

 

* 가사 스크립트와 최대한 싱크를 맞게 하려면 시작 전에 볼륨 0으로 하시고 한번 틀었다 끄셔야 합니다. 아니면 로딩에 걸려 노래가 본래 타이밍보다 늦게 틀어집니다.


 

5. 스크립트

 

const CONFIG = {
    panel_name: "lyric_panel", // 가사가 표시될 기준 토큰(이름 필수)
    main_size: 18,            // 윗줄(원문) 글자 크기
    sub_size: 24,             // 아랫줄(번역) 글자 크기
    main_offset: -15,         // 패널 기준 윗줄 높이 간격
    sub_offset: 15,           // 패널 기준 아랫줄 높이 간격
    start_volume: 50,         // 재생 시작 시 볼륨 (0~100)
    stop_volume: 0,           // 종료 시 볼륨 (0으로 소리 제거)
    
    // [싱크 보정] 
    // 가사가 노래보다 느리면? 숫자를 줄이세요 (예: -2.0)
    // 가사가 노래보다 빠르면? 숫자를 늘리세요 (예: 1.0)
    sync_offset: 0.0, 
    
    // [기본 색상] 핸드아웃에 설정이 없을 때 사용될 색상
    default_main_color: "#AAAAAA", 
    default_sub_color: "#FFFFFF"
};

var LyricState = { 
    timer: null,
    startTime: 0,
    queue: [],
    lastTime: -1,
    endTime: 0,
    currentMainColor: "",
    currentSubColor: "" 
};

on("chat:message", function(msg) {
    if (msg.type !== "api") return;

    if (msg.content.indexOf("!노래시작 ") === 0) {
        var inputName = msg.content.replace("!노래시작 ", "").trim();
        var pageId = Campaign().get("playerpageid");
        
        if(LyricState.timer) { clearInterval(LyricState.timer); LyricState.timer = null; }

        var track = findObjs({_type: 'jukeboxtrack'}).find(t => t.get('title').includes(inputName));
        var handout = findObjs({_type: "handout"}).find(h => h.get("name").includes(inputName));

        if (track && handout) {
            track.set({volume: CONFIG.start_volume, playing: true, softstop: false});
            
            handout.get("notes", function(notes) {
                parseColorsStrong(notes);
                LyricState.queue = parseLyrics(notes);
                
                if (LyricState.queue.length > 0) {
                    LyricState.endTime = LyricState.queue[LyricState.queue.length - 1].time;
                }

                LyricState.startTime = Date.now() + (CONFIG.sync_offset * 1000);
                LyricState.lastTime = -1;
                startLyricTimer(pageId);
                log(inputName + " 재생 (보정: " + CONFIG.sync_offset + "s / Color: " + LyricState.currentSubColor + ")");
            });
        }
    }

    if (msg.content === "!노래중지") {
        stopLyrics(Campaign().get("playerpageid"));
    }
});

function parseColorsStrong(notes) {
    var cleanText = notes.replace(/<[^>]*>/g, " ")
                         .replace(/&nbsp;/g, " ")
                         .replace(/\s+/g, " ");

    var mainMatch = cleanText.match(/main_color\s*:\s*(#[0-9A-Fa-f]{6})/i);
    var subMatch = cleanText.match(/sub_color\s*:\s*(#[0-9A-Fa-f]{6})/i);
    
    LyricState.currentMainColor = mainMatch ? mainMatch[1] : CONFIG.default_main_color;
    LyricState.currentSubColor = subMatch ? subMatch[1] : CONFIG.default_sub_color;
}

function stopLyrics(pageId) {
    if(LyricState.timer) { clearInterval(LyricState.timer); LyricState.timer = null; }
    LyricState.queue = [];
    LyricState.lastTime = -1;
    
    findObjs({_type: 'jukeboxtrack', playing: true}).forEach(t => { 
        t.set({volume: CONFIG.stop_volume}); 
    });

    findObjs({_type: "text", _pageid: pageId}).forEach(obj => {
        var size = obj.get("font_size");
        if (size === CONFIG.main_size || size === CONFIG.sub_size) obj.remove();
    });
}

function parseLyrics(notes) {
    var q = [];
    var rawText = notes.replace(/<br\s*\/?>|<\/p>|<div>/gi, "\n").replace(/<[^>]*>/g, "");
    var lines = rawText.split("\n").map(l => l.trim()).filter(l => l !== "");
    for (var i = 0; i < lines.length; i++) {
        var m = lines[i].match(/\((\d{1,2}):(\d{2})\)/);
        if (m) {
            q.push({ time: parseInt(m[1]) * 60 + parseInt(m[2]), sub: lines[i-1] || "", main: lines[i-2] || "" });
        }
    }
    return q.sort((a, b) => a.time - b.time);
}

function startLyricTimer(pageId) {
    LyricState.timer = setInterval(function() {
        var totalElapsedMs = Date.now() - LyricState.startTime;
        var currentTime = Math.floor(totalElapsedMs / 1000);
        
        if (currentTime < 0) return;
        
        if (currentTime > LyricState.endTime) { 
            stopLyrics(pageId); 
            return; 
        }

        if (currentTime !== LyricState.lastTime) {
            LyricState.lastTime = currentTime;
            var item = LyricState.queue.find(q => q.time === currentTime);
            if (item) {
                findObjs({_type: "text", _pageid: pageId}).forEach(obj => {
                    var s = obj.get("font_size");
                    if (s === CONFIG.main_size || s === CONFIG.sub_size) obj.remove();
                });
                updateLyricDisplay(pageId, item.main, item.sub);
            }
        }
    }, 250);
}

function updateLyricDisplay(pageId, txtMain, txtSub) {
    var panel = findObjs({_type: "graphic", name: CONFIG.panel_name, _pageid: pageId})[0];
    if (!panel) return;
    var common = { _pageid: pageId, left: panel.get("left"), layer: "objects", textAlign: "center" };
    
    createObj("text", _.extend(common, { top: panel.get("top") + CONFIG.main_offset, text: txtMain, font_size: CONFIG.main_size, color: LyricState.currentMainColor }));
    createObj("text", _.extend(common, { top: panel.get("top") + CONFIG.sub_offset, text: txtSub, font_size: CONFIG.sub_size, color: LyricState.currentSubColor }));
}