perf(play): 음성접속과 곡 해석 병렬화 + 첫 곡 yt-dlp 예열로 첫 재생 단축

기존: player_play 가 channelJoin() 완료 후에야 search() 를 시작(직렬)하고,
첫 곡은 playNext 시점에 yt-dlp 리졸브(콜드 3.6~4.2s)를 그제서야 수행했다.

- LavalinkManager.search 를 resolveQuery(해석) + addResolved(큐 반영)로 분리.
  resolveQuery 는 플레이어가 필요 없어 음성접속과 동시에 실행 가능.
- resolveQuery 가 해석 직후 prewarmTrack() 으로 첫 곡 오디오 리졸브를 즉시 예열.
  GuildPlayer.resolvePlayable 과 동일한 target 계산식을 써 캐시가 확실히 적중하게 함.
- RedisClient player_play/player_playlist: 해석을 먼저 출발시키고 음성접속과 병렬 진행.
- 구간 계측 로그 추가(음성접속/해석대기/오디오 리졸브 대기).

검증: tsc 통과. 실측 A/B(동일 조건, 미캐시 곡):
  기존 직렬 접속2.00s+리졸브3.03s = 5.03s → 신규 병렬 2.00s+0.97s = 2.98s (약 2s 단축).
This commit is contained in:
2026-09-17 13:11:11 +09:00
parent 1bb1d10b25
commit 6de959be43
3 changed files with 98 additions and 32 deletions

View File

@@ -300,7 +300,9 @@ export class GuildPlayer {
public async playNext() { public async playNext() {
const track = this.queue[0]; const track = this.queue[0];
if (!track) return this.end(); if (!track) return this.end();
const tResolve = Date.now();
const encoded = await this.getResolved(track); const encoded = await this.getResolved(track);
Logger.log(`[GuildPlayer] 오디오 리졸브 대기 ${Date.now() - tResolve}ms (예열 적중이면 0에 가까움)`);
// 다음 곡을 미리 리졸브해 곡 전환 지연 제거 // 다음 곡을 미리 리졸브해 곡 전환 지연 제거
this.prefetch(this.queue[1]); this.prefetch(this.queue[1]);
await this.player.playTrack({ track: { encoded } }); await this.player.playTrack({ track: { encoded } });

View File

@@ -102,14 +102,16 @@ export class LavalinkManager {
this.players.delete(guildId); this.players.delete(guildId);
} }
async search(guildId: string, query: string, userId: string, player?: GuildPlayer) { /**
* 쿼리를 Lavalink 트랙으로 '해석'만 한다. 플레이어(=음성접속)가 필요 없으므로
* 음성채널 접속과 동시에 돌릴 수 있다(첫 재생 지연 단축).
* 해석이 끝나면 첫 곡의 yt-dlp 리졸브를 즉시 예열(prewarm)해, 음성접속이 끝날 즈음엔
* 오디오 URL이 이미 준비되도록 한다.
* 못 찾으면 null.
*/
public async resolveQuery(query: string): Promise<{ tracks: Track[]; playlist: boolean; flags: Set<string> } | null> {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes); const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) { if (!node) throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
this.delPlayer(guildId);
throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
}
player = player ?? this.getPlayer(guildId);
if (!player) return;
const { isUrl, text, flags } = parseLink(query.trim()); const { isUrl, text, flags } = parseLink(query.trim());
// 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면 // 기본: 유튜브뮤직(YoutubeMusic.getSearchUrl) 우선 → 결과가 없거나 애매(커버/방송/무관)하면
// getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다. // getSearchUrl이 null을 주고, 공식 오디오를 잘 잡는 `ytsearch:... Topic`로 폴백한다.
@@ -133,29 +135,69 @@ export class LavalinkManager {
const result = await node.rest.resolve(searchText); const result = await node.rest.resolve(searchText);
if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) { if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`); if (result?.loadType === LoadType.ERROR) Logger.error(`[LavalinkManager] loadtype ERROR: ${result.data.message}`);
return null;
}
let tracks: Track[] = [];
let playlist = false;
if (result.loadType === LoadType.PLAYLIST) {
tracks = result.data.tracks;
playlist = true;
} else if (result.loadType === LoadType.TRACK) {
tracks = [result.data];
} else if (result.loadType === LoadType.SEARCH) {
tracks = result.data.length > 0 ? [result.data[0]] : [];
}
if (tracks.length === 0) return null;
// 첫 곡 오디오(yt-dlp) 리졸브를 지금 바로 걸어둔다 — 음성접속과 겹쳐 진행된다.
this.prewarmTrack(tracks[0]);
return { tracks, playlist, flags };
}
/**
* resolveQuery 결과를 플레이어 큐에 반영한다(기존 search 후반부와 동일 동작).
*/
public async addResolved(
player: GuildPlayer,
resolved: { tracks: Track[]; playlist: boolean; flags: Set<string> },
userId: string,
) {
if (resolved.playlist) {
await player.addTracks(resolved.flags.has("s") ? shuffle(resolved.tracks) : resolved.tracks, userId);
return;
}
await player.addTrack(resolved.tracks[0], userId);
}
/**
* 트랙의 오디오 URL 리졸브(yt-dlp)를 미리 걸어둔다(fire-and-forget).
* GuildPlayer.resolvePlayable 과 '동일한' 대상 문자열을 써야 캐시가 적중하므로 계산식을 맞춘다.
*/
public prewarmTrack(track: Track) {
try {
const info: any = (track as any).info ?? {};
const src = info.sourceName;
if (src !== "youtube" && src !== "spotify" && src !== "youtubemusic") return;
const target = (src === "youtube" && info.uri) ? info.uri : `${info.author ?? ""} ${info.title ?? ""}`.trim();
if (!target) return;
fetch(`${Config.ytresolverUrl}/resolve?url=${encodeURIComponent(target)}`, { signal: AbortSignal.timeout(45000) }).catch(() => {});
} catch { /* prewarm 실패는 무시 (실제 재생 시 정상 경로로 리졸브) */ }
}
async search(guildId: string, query: string, userId: string, player?: GuildPlayer) {
const node = this.shoukaku.options.nodeResolver(this.shoukaku.nodes);
if (!node) {
this.delPlayer(guildId);
throw new ReferenceError(`[LavalinkManager] lavalink node is missing`);
}
player = player ?? this.getPlayer(guildId);
if (!player) return;
const resolved = await this.resolveQuery(query);
if (!resolved) {
// 노래 못찾았을때 // 노래 못찾았을때
player.errMsg(`노래를 찾을수 없습니다.`); player.errMsg(`노래를 찾을수 없습니다.`);
return; return;
} }
if (result.loadType === LoadType.PLAYLIST) { await this.addResolved(player, resolved, userId);
player.addTracks(
flags?.has("s") ? shuffle(result.data.tracks) : result.data.tracks,
userId
);
return;
}
if (result.loadType === LoadType.TRACK) {
player.addTrack(result.data, userId);
return;
}
if (result.loadType === LoadType.SEARCH) {
if (result.data.length === 0) {
player.errMsg(`노래를 찾을수 없습니다.`);
return;
}
player.addTrack(result.data[0], userId);
return;
}
} }
public async youtubeSearch(query: string): Promise<Track[]> { public async youtubeSearch(query: string): Promise<Track[]> {

View File

@@ -73,16 +73,29 @@ class RedisClientClass {
const guild = await getGuildById(data.serverId); const guild = await getGuildById(data.serverId);
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
// 곡 해석(검색+Lavalink+yt-dlp 예열)은 음성접속과 무관하므로 먼저 출발시켜 둘을 겹친다.
const t0 = Date.now();
const resolving = lavalinkManager.resolveQuery(data.track.url)
.catch((e) => { Logger.warn(`[player_play] 곡 해석 실패: ${String(e)}`); return null; });
let player = lavalinkManager.getPlayer(guild.id); let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId); const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) { if (!player) {
if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); if (!voiceChannel) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
player = (await channelJoin(guild, voiceChannel.id)).player; player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
await resolving;
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
} }
if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); const tJoined = Date.now();
await lavalinkManager.search(guild.id, data.track.url, data.userId, player); if (!player) { await resolving; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
const resolved = await resolving;
const tResolved = Date.now();
if (!resolved) {
player.errMsg(`노래를 찾을수 없습니다.`);
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
}
await lavalinkManager.addResolved(player, resolved, data.userId);
Logger.log(`[player_play] 음성접속 ${tJoined - t0}ms / 곡해석 대기 ${tResolved - tJoined}ms / 큐반영까지 ${Date.now() - t0}ms`);
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" })); await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
} }
if (data.action === "player_playlist") { if (data.action === "player_playlist") {
@@ -92,16 +105,25 @@ class RedisClientClass {
const guild = await getGuildById(data.serverId); const guild = await getGuildById(data.serverId);
if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); if (!guild) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." })); if (!(await this.isMember(guild, data.userId))) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "이 서버의 멤버가 아닙니다." }));
// 재생목록 해석도 음성접속과 병렬로 진행한다.
const resolvingPl = lavalinkManager.resolveQuery(data.playlistUrl)
.catch((e) => { Logger.warn(`[player_playlist] 해석 실패: ${String(e)}`); return null; });
let player = lavalinkManager.getPlayer(guild.id); let player = lavalinkManager.getPlayer(guild.id);
const voiceChannel = await getVoiceChannelById(guild, data.userId); const voiceChannel = await getVoiceChannelById(guild, data.userId);
if (!player) { if (!player) {
if (!voiceChannel) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); if (!voiceChannel) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); }
player = (await channelJoin(guild, voiceChannel.id)).player; player = (await channelJoin(guild, voiceChannel.id)).player;
} else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) { } else if (!voiceChannel || voiceChannel.id !== player.voiceChannelId) {
await resolvingPl;
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." })); return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "봇과 같은 음성채널에 있어야 합니다." }));
} }
if (!player) return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); if (!player) { await resolvingPl; return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." })); }
await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player); const resolvedPl = await resolvingPl;
if (!resolvedPl) {
player.errMsg(`노래를 찾을수 없습니다.`);
return await this.reply(resultKey, 60, JSON.stringify({ success: false, message: "노래를 찾을수 없습니다." }));
}
await lavalinkManager.addResolved(player, resolvedPl, data.userId);
await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" })); await this.reply(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
} }
if (data.action === "player_now") { if (data.action === "player_now") {