feat(voice): per-user TTS voice selection via /목소리
Until now every TTS message was synthesized with VoiceType.가람
regardless of who spoke. This adds a user-scoped voice preference
persisted in SQLite so each member can pick their own voice and
keep it across bot restarts.
Changes
- db/schema.sql: add nullable `voice_type` column to `users`.
- src/utils/Database.ts:
- run a PRAGMA-driven ALTER TABLE migration so existing DBs gain
the column without dropping data.
- add `DB.user.setVoice(guildId, userId, name, voiceType)` that
upserts the row.
- src/classes/TTSClient.ts:
- export `DEFAULT_VOICE` (= 가람).
- resolve the speaking member's stored voice in `tts()` and
thread it through `getSource()` instead of hardcoding 가람.
- validate stored slug against `VoiceType` so stale/unknown
values silently fall back to the default.
- src/commands/voice.ts (new):
- `/목소리` slash command shows the user's current voice and a
StringSelectMenu of all `VoiceType` entries (현재 + 이전 보이스
모두). Selection writes to `users.voice_type` and confirms
ephemerally.
- defensively creates a guild row if `/tts channel register`
hasn't run yet, to satisfy the ON DELETE CASCADE FK.
Deploy
Run `npm run prod` after pulling so Discord sees the new
`/목소리` command. No env or config changes required.
This commit is contained in:
1
db/db.d.ts
vendored
1
db/db.d.ts
vendored
@@ -8,4 +8,5 @@ export interface UserType {
|
|||||||
guild_id: string;
|
guild_id: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
voice_type?: string | null;
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
guild_id TEXT NOT NULL, -- 소속 길드 ID, guilds.id를 참조
|
guild_id TEXT NOT NULL, -- 소속 길드 ID, guilds.id를 참조
|
||||||
id TEXT NOT NULL, -- 유저 ID
|
id TEXT NOT NULL, -- 유저 ID
|
||||||
name TEXT NOT NULL, -- 유저 이름 (캐싱용)
|
name TEXT NOT NULL, -- 유저 이름 (캐싱용)
|
||||||
|
voice_type TEXT, -- TTS 목소리 슬러그 (NULL = 기본)
|
||||||
|
|
||||||
-- 복합 기본키: 같은 길드 안에서 id는 중복 불가
|
-- 복합 기본키: 같은 길드 안에서 id는 중복 불가
|
||||||
PRIMARY KEY (guild_id, id),
|
PRIMARY KEY (guild_id, id),
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { client, signature } from "../index";
|
import { client, signature } from "../index";
|
||||||
import { ChannelType, Guild, GuildMember, TextChannel, VoiceBasedChannel, VoiceChannel } from "discord.js";
|
import { ChannelType, Guild, GuildMember, TextChannel, VoiceBasedChannel, VoiceChannel } from "discord.js";
|
||||||
import { Config } from "../utils/Config";
|
import { Config } from "../utils/Config";
|
||||||
|
import { DB } from "../utils/Database";
|
||||||
import { Logger } from "../utils/Logger";
|
import { Logger } from "../utils/Logger";
|
||||||
import { VoiceSession } from "./VoiceSession";
|
import { VoiceSession } from "./VoiceSession";
|
||||||
|
|
||||||
import { textToSpeech, VoiceType } from "../utils/tts/Chzzk";
|
import { textToSpeech, VoiceType } from "../utils/tts/Chzzk";
|
||||||
// import { textToSpeech } from "../utils/tts/Google";
|
// import { textToSpeech } from "../utils/tts/Google";
|
||||||
|
|
||||||
|
// /목소리 명령어로 설정되지 않은 유저에게 사용할 기본 목소리.
|
||||||
|
export const DEFAULT_VOICE: VoiceType = VoiceType.가람;
|
||||||
|
const VOICE_VALUES = new Set<string>(Object.values(VoiceType));
|
||||||
|
|
||||||
const URL_RE = /https?:\/\/[^\s<>"']+/gi;
|
const URL_RE = /https?:\/\/[^\s<>"']+/gi;
|
||||||
const DOMAIN_LABELS: { test: (h: string) => boolean; label: string }[] = [
|
const DOMAIN_LABELS: { test: (h: string) => boolean; label: string }[] = [
|
||||||
{ test: h => h === "youtu.be" || h.endsWith("youtube.com"), label: "유튜브 주소", },
|
{ test: h => h === "youtu.be" || h.endsWith("youtube.com"), label: "유튜브 주소", },
|
||||||
@@ -62,7 +67,8 @@ export class TTSClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
text = this.textEditor(channel.guild, text);
|
text = this.textEditor(channel.guild, text);
|
||||||
const buf = await this.getSource(text);
|
const voice = this.resolveUserVoice(channel.guild.id, member.id);
|
||||||
|
const buf = await this.getSource(text, voice);
|
||||||
if (!buf) return;
|
if (!buf) return;
|
||||||
|
|
||||||
const session = this.getSession(channel.guild, voiceChannel);
|
const session = this.getSession(channel.guild, voiceChannel);
|
||||||
@@ -91,7 +97,15 @@ export class TTSClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async getSource(text: string): Promise<Buffer | null> {
|
// DB 에 저장된 유저별 목소리를 가져온다. 없거나 알 수 없는 값이면 기본값.
|
||||||
|
private resolveUserVoice(guildId: string, userId: string): VoiceType {
|
||||||
|
const row = DB.user.get(guildId, userId);
|
||||||
|
const stored = row?.voice_type;
|
||||||
|
if (stored && VOICE_VALUES.has(stored)) return stored as VoiceType;
|
||||||
|
return DEFAULT_VOICE;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSource(text: string, voice: VoiceType): Promise<Buffer | null> {
|
||||||
// 시그니처 목록이 비어 있으면 split을 건너뛰고 원문을 한 번에 합성한다.
|
// 시그니처 목록이 비어 있으면 split을 건너뛰고 원문을 한 번에 합성한다.
|
||||||
// (SignatureClient.regex가 빈 목록일 때 never-match 패턴을 돌려주지만,
|
// (SignatureClient.regex가 빈 목록일 때 never-match 패턴을 돌려주지만,
|
||||||
// 소비자 측에서도 명시적으로 가드해 의도를 분명히 한다.)
|
// 소비자 측에서도 명시적으로 가드해 의도를 분명히 한다.)
|
||||||
@@ -106,11 +120,11 @@ export class TTSClient {
|
|||||||
if (buf) {
|
if (buf) {
|
||||||
bufferList.push(buf);
|
bufferList.push(buf);
|
||||||
} else {
|
} else {
|
||||||
const buf = await textToSpeech(this.textReplace(part), VoiceType.가람).catch(() => null);
|
const buf = await textToSpeech(this.textReplace(part), voice).catch(() => null);
|
||||||
if (buf) bufferList.push(buf);
|
if (buf) bufferList.push(buf);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const buf = await textToSpeech(this.textReplace(part), VoiceType.가람).catch(() => null);
|
const buf = await textToSpeech(this.textReplace(part), voice).catch(() => null);
|
||||||
if (buf) bufferList.push(buf);
|
if (buf) bufferList.push(buf);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
src/commands/voice.ts
Normal file
131
src/commands/voice.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { client } from "../index";
|
||||||
|
import { Command } from "../types/Command";
|
||||||
|
import { DB } from "../utils/Database";
|
||||||
|
import { DEFAULT_VOICE } from "../classes/TTSClient";
|
||||||
|
import { VoiceType } from "../utils/tts/Chzzk";
|
||||||
|
import {
|
||||||
|
ActionRowBuilder,
|
||||||
|
CacheType,
|
||||||
|
ChannelType,
|
||||||
|
ChatInputApplicationCommandData,
|
||||||
|
ChatInputCommandInteraction,
|
||||||
|
Message,
|
||||||
|
StringSelectMenuBuilder,
|
||||||
|
StringSelectMenuInteraction,
|
||||||
|
} from "discord.js";
|
||||||
|
|
||||||
|
// VoiceType 은 enum: 키 = 한글 표시명, 값 = 슬러그.
|
||||||
|
// Object.entries 의 키 순서는 enum 선언 순서를 따른다.
|
||||||
|
const VOICE_ENTRIES = Object.entries(VoiceType) as [string, VoiceType][];
|
||||||
|
|
||||||
|
const nameOf = (slug: VoiceType): string =>
|
||||||
|
VOICE_ENTRIES.find(([_, v]) => v === slug)?.[0] ?? slug;
|
||||||
|
|
||||||
|
/** /목소리 명령어: 유저 개인별 TTS 목소리를 DB에 저장한다. */
|
||||||
|
export default class implements Command {
|
||||||
|
name = "목소리";
|
||||||
|
visible = true;
|
||||||
|
aliases: string[] = ["voice"];
|
||||||
|
description: string = "내 TTS 목소리를 선택합니다.";
|
||||||
|
metaData: ChatInputApplicationCommandData = {
|
||||||
|
name: this.name,
|
||||||
|
description: this.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
async slashRun(interaction: ChatInputCommandInteraction) {
|
||||||
|
if (!interaction.guildId) {
|
||||||
|
await interaction.editReply({ embeds: [ client.mkembed({
|
||||||
|
title: "서버 안에서만 사용 가능합니다.",
|
||||||
|
color: "DarkRed",
|
||||||
|
}) ] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = DB.user.get(interaction.guildId, interaction.user.id);
|
||||||
|
const current = (row?.voice_type && Object.values(VoiceType).includes(row.voice_type as VoiceType))
|
||||||
|
? row.voice_type as VoiceType
|
||||||
|
: DEFAULT_VOICE;
|
||||||
|
|
||||||
|
const select = new StringSelectMenuBuilder()
|
||||||
|
.setCustomId(this.name)
|
||||||
|
.setPlaceholder("사용할 목소리를 선택해주세요.")
|
||||||
|
.addOptions(
|
||||||
|
VOICE_ENTRIES.map(([label, value]) => ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
default: value === current,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await interaction.editReply({
|
||||||
|
embeds: [ client.mkembed({
|
||||||
|
title: "내 TTS 목소리 설정",
|
||||||
|
description: [
|
||||||
|
`현재 목소리: **${nameOf(current)}**`,
|
||||||
|
"",
|
||||||
|
"아래에서 원하는 목소리를 선택해주세요.",
|
||||||
|
"선택은 본인에게만 적용되며, 봇을 재시작해도 유지됩니다.",
|
||||||
|
].join("\n"),
|
||||||
|
}) ],
|
||||||
|
components: [
|
||||||
|
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(select),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async menuRun(interaction: StringSelectMenuInteraction<CacheType>, args: string[]) {
|
||||||
|
if (!interaction.guildId) {
|
||||||
|
await interaction.editReply({ embeds: [ client.mkembed({
|
||||||
|
title: "서버 안에서만 사용 가능합니다.",
|
||||||
|
color: "DarkRed",
|
||||||
|
}) ] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = args[0];
|
||||||
|
if (!value || !Object.values(VoiceType).includes(value as VoiceType)) {
|
||||||
|
await interaction.editReply({ embeds: [ client.mkembed({
|
||||||
|
title: "알 수 없는 목소리입니다.",
|
||||||
|
description: "선택지를 다시 확인해주세요.",
|
||||||
|
color: "DarkRed",
|
||||||
|
}) ] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외래키 제약 대비: 길드 행이 없으면 먼저 생성.
|
||||||
|
// (/tts channel register 를 한 번도 안 돌린 서버에서도 /목소리 자체는 동작하도록.)
|
||||||
|
if (!DB.guild.get(interaction.guildId)) {
|
||||||
|
DB.guild.set({
|
||||||
|
id: interaction.guildId,
|
||||||
|
name: interaction.guild?.name ?? "",
|
||||||
|
channel_id: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = DB.user.setVoice(
|
||||||
|
interaction.guildId,
|
||||||
|
interaction.user.id,
|
||||||
|
interaction.user.username,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
if (!ok) {
|
||||||
|
await interaction.editReply({ embeds: [ client.mkembed({
|
||||||
|
title: "저장 실패",
|
||||||
|
description: "DB 저장 중 오류가 발생했습니다.",
|
||||||
|
color: "DarkRed",
|
||||||
|
}) ] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await interaction.editReply({ embeds: [ client.mkembed({
|
||||||
|
title: "목소리 설정 완료",
|
||||||
|
description: `이제부터 **${nameOf(value as VoiceType)}** 목소리로 읽어드립니다.`,
|
||||||
|
}) ] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async messageRun(message: Message) {
|
||||||
|
if (message.channel?.type !== ChannelType.GuildText) return;
|
||||||
|
await message.channel.send({ content: `\`/${this.name}\` (슬래시) 명령어로만 사용할 수 있습니다.` })
|
||||||
|
.then(m => client.msgDelete(m, 5));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,15 @@ const schemaPath = join(process.cwd(), "db/schema.sql");
|
|||||||
const schema = readFileSync(schemaPath, "utf-8");
|
const schema = readFileSync(schemaPath, "utf-8");
|
||||||
database.exec(schema);
|
database.exec(schema);
|
||||||
|
|
||||||
|
// 마이그레이션: 기존 DB 에 voice_type 컬럼이 없으면 추가
|
||||||
|
// (CREATE TABLE IF NOT EXISTS 는 이미 존재하는 테이블에 컬럼을 추가하지 않으므로
|
||||||
|
// PRAGMA 로 직접 확인한다.)
|
||||||
|
const userCols = database.prepare("PRAGMA table_info(users)").all() as { name: string }[];
|
||||||
|
if (!userCols.some(c => c.name === "voice_type")) {
|
||||||
|
database.exec("ALTER TABLE users ADD COLUMN voice_type TEXT");
|
||||||
|
Logger.ready("DB 마이그레이션: users.voice_type 컬럼 추가");
|
||||||
|
}
|
||||||
|
|
||||||
Logger.ready("DB 활성화!");
|
Logger.ready("DB 활성화!");
|
||||||
|
|
||||||
const stmt = {
|
const stmt = {
|
||||||
@@ -108,5 +117,21 @@ export const DB = {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// 유저 행이 없으면 insert, 있으면 update.
|
||||||
|
// (sqlite UPSERT 대신 명시적 분기 — 기존 set/update 패턴 유지)
|
||||||
|
setVoice(guildId: string, id: string, name: string, voiceType: string) {
|
||||||
|
try {
|
||||||
|
const existing = stmt.user.get.get(guildId, id) as UserType | undefined;
|
||||||
|
if (existing) {
|
||||||
|
stmt.user.update({ guild_id: guildId, id, name, voice_type: voiceType });
|
||||||
|
} else {
|
||||||
|
stmt.user.insert({ guild_id: guildId, id, name, voice_type: voiceType });
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
Logger.error(String(err));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user