Compare commits
18 Commits
d6b36c43c2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c87a16a683 | ||
|
|
9eed231244 | ||
|
|
94a39e0d45 | ||
|
|
1d383d9a03 | ||
|
|
1841828f7a | ||
|
|
2002b1cc12 | ||
|
|
204b813ecc | ||
|
|
499852b2a7 | ||
|
|
08de63b448 | ||
|
|
acdaa4734f | ||
|
|
35569ddd88 | ||
|
|
fe10ed1bd9 | ||
|
|
9123afc14e | ||
|
|
e22cb53ccf | ||
|
|
7b56d2e055 | ||
|
|
9288127561 | ||
|
|
7876283e08 | ||
|
|
6bfef454b4 |
38
.env.example
Normal file
38
.env.example
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Discord 봇 토큰
|
||||||
|
TOKEN=
|
||||||
|
|
||||||
|
# Discord 애플리케이션 ID
|
||||||
|
APPID=
|
||||||
|
|
||||||
|
# 명령어 prefix (예: !)
|
||||||
|
PREFIX=!
|
||||||
|
|
||||||
|
# SQLite DB 파일 경로 (예: ./db/tts.db)
|
||||||
|
DBPATH=./db/tts.db
|
||||||
|
|
||||||
|
# 개발 길드 ID. `npm run dev` 시 슬래시 명령이 이 길드에만 등록되어 즉시 반영되고,
|
||||||
|
# `npm run prod` 실행 시 DEV=true 면 이 길드의 기존 슬래시 명령을 먼저 wipe 한 뒤 전역 등록
|
||||||
|
GUILDID=
|
||||||
|
|
||||||
|
# 치지직(Chzzk) 인증 쿠키
|
||||||
|
CHZZK_NID_AUT=
|
||||||
|
CHZZK_NID_SES=
|
||||||
|
|
||||||
|
# 시그니처 서버 host:port (선택, 미설정 시 192.168.10.5:2967)
|
||||||
|
SIGNATURE_HOST=
|
||||||
|
|
||||||
|
# TTS 외부 경로 (선택, 현재 호출처 없음. 향후 사용 대비 환경변수만 받아둠)
|
||||||
|
TTSPATH=
|
||||||
|
|
||||||
|
# 개발 모드 플래그 (선택, 기본 false)
|
||||||
|
# - 봇 기동 시(clientReady): true 면 GUILDID 길드에 슬래시 명령을 길드 한정 등록 (즉시 반영용)
|
||||||
|
# - `npm run prod` 실행 시: true 면 GUILDID 길드의 기존 슬래시를 먼저 wipe 한 뒤 전역 등록 (중복 정리)
|
||||||
|
# false 면 전역 등록만 (운영 배포)
|
||||||
|
DEV=false
|
||||||
|
|
||||||
|
# true 면 명령어 오류 스택을 콘솔에 출력 (선택, 기본 false)
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# 치환 사전(JSON 배열). 기본 사전(def_replaceObj)과 병합 (선택)
|
||||||
|
# 예: REPLACETEXT=[{"ㅋㅋ":"크크"}]
|
||||||
|
REPLACETEXT=[{}]
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
FROM node:20-alpine
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ffmpeg python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -10,8 +12,6 @@ RUN npm install
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN mkdir -p dist
|
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
|
|||||||
118
README.md
118
README.md
@@ -1,7 +1,117 @@
|
|||||||
# tts_bot
|
# tts_bot
|
||||||
|
|
||||||
## 설명
|
Discord 음성채널에서 채팅을 읽어주는 TTS 봇. TypeScript + discord.js 기반.
|
||||||
* text to speech 봇
|
|
||||||
|
|
||||||
## 코멘트
|
## 주요 기능
|
||||||
* 현재 제작중...
|
|
||||||
|
- 지정한 텍스트 채널의 채팅을 음성채널에서 자동 낭독 (`/tts channel register`로 채널 지정)
|
||||||
|
- 치지직(Chzzk) 알림 TTS 엔진 사용 (`utils/tts/Chzzk.ts`). 현재 코드는 `VoiceType.가람` 고정 (`VoiceType` enum에 다른 보이스도 정의돼 있어 코드 한 줄 수정으로 교체 가능)
|
||||||
|
- 대체 엔진으로 Google Translate TTS 구현체(`utils/tts/Google.ts`)가 포함돼 있으나 현재 비활성(`TTSClient.ts`에서 import 주석 처리). 사용하려면 import 교체 필요
|
||||||
|
- 시그니처(짤·효과음) 재생: 외부 시그니처 서버(`192.168.10.5:2967`)와 WebSocket으로 동기화해 채팅 중 키워드 매칭 시 mp3 삽입
|
||||||
|
- URL, 멘션, 이모지, 자주 쓰는 초성(ㄹㅇ, ㅇㅋ 등)을 자동 치환해 읽음
|
||||||
|
- 10분 idle 후 음성채널 자동 퇴장, 사용자 음성채널 이동 시 따라가기
|
||||||
|
|
||||||
|
## 명령어
|
||||||
|
|
||||||
|
| 명령어 | 설명 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/tts channel make` | "TTS채널" 텍스트 채널을 새로 만들고 등록 |
|
||||||
|
| `/tts channel register [channel|channel_id]` | 기존 텍스트 채널을 TTS 입력 채널로 등록 |
|
||||||
|
| `/시그니처` (`/signature`) | 시그니처 관리 사이트 링크 |
|
||||||
|
| `/help` (`/도움말`) | 명령어 목록 |
|
||||||
|
| `/ping` (`/핑`) | 응답 지연 확인 |
|
||||||
|
|
||||||
|
메시지 prefix(`PREFIX` 환경변수)로도 `help`/`ping`/`시그니처`/`example`은 동일하게 호출된다. `tts`는 슬래시 전용이며, prefix `tts`는 현재 더미 응답("예시 명령어")만 보낸다.
|
||||||
|
|
||||||
|
## 기술 스택
|
||||||
|
|
||||||
|
- Node.js 20 (Dockerfile 기준) / TypeScript (strict 모드)
|
||||||
|
- `discord.js` v14, `@discordjs/voice`, `@discordjs/opus`
|
||||||
|
- `better-sqlite3` — 길드별 등록 채널 저장 (`db/schema.sql`)
|
||||||
|
- `fluent-ffmpeg` + `ffmpeg-static` — 시그니처 mp3 볼륨 조절, PCM 변환
|
||||||
|
- `ws` — 시그니처 서버 동기화
|
||||||
|
- `axios` — Chzzk / Google TTS HTTP 호출
|
||||||
|
|
||||||
|
## 디렉터리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
index.ts # 엔트리: 싱글톤(client, handler, tts, signature) 구성
|
||||||
|
classes/
|
||||||
|
BotClient.ts # discord.js Client 확장 (embed/메시지 삭제 헬퍼)
|
||||||
|
Handler.ts # commands/ 자동 로드 및 슬래시 라우팅
|
||||||
|
TTSClient.ts # 메시지 → TTS 변환 파이프라인
|
||||||
|
VoiceSession.ts # 길드별 음성 연결/플레이어 관리
|
||||||
|
SignatureClient.ts # 시그니처 서버 WS 연결 + 파일 캐싱
|
||||||
|
commands/ # tts, signature, help, ping, example
|
||||||
|
events/ # clientReady, interactionCreate, messageCreate, voiceStateUpdate
|
||||||
|
utils/
|
||||||
|
Config.ts # 환경변수 게터 (누락 시 ReferenceError)
|
||||||
|
Database.ts # better-sqlite3 래퍼 (guild/user CRUD)
|
||||||
|
Logger.ts # colors 기반 타임스탬프 로거
|
||||||
|
Transcode.ts # mp3 Buffer → 48k/16bit/stereo PCM 스트림
|
||||||
|
Prod-commands.ts # 전역 슬래시 명령어 일괄 등록 스크립트
|
||||||
|
tts/ # Chzzk, Google, Utils(치환 사전)
|
||||||
|
db/
|
||||||
|
schema.sql # guilds, users 테이블
|
||||||
|
db.d.ts # 타입 정의
|
||||||
|
```
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
`.env` 또는 시스템 환경변수로 설정. 모든 값은 `Config.ts`에서 trim 후 읽음.
|
||||||
|
|
||||||
|
| 키 | 필수 | 설명 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `APPID` | ✅ | Discord 애플리케이션 ID |
|
||||||
|
| `TOKEN` | ✅ | 봇 토큰 |
|
||||||
|
| `PREFIX` | ✅ | 메시지 명령어 prefix (예: `!`) |
|
||||||
|
| `DBPATH` | ✅ | SQLite 파일 경로 (예: `./db/data.db`) |
|
||||||
|
| `CHZZK_NID_AUT` | ✅ | 네이버 NID_AUT 쿠키 (치지직 TTS 인증) |
|
||||||
|
| `CHZZK_NID_SES` | ✅ | 네이버 NID_SES 쿠키 |
|
||||||
|
| `GUILDID` | △ | `DEV=true`일 때만 사용 (DEV 길드 한정 슬래시 등록 대상). 그 외에는 미설정이어도 무방 |
|
||||||
|
| `DEV` | 선택 | `true`면 글로벌 대신 `GUILDID`에만 슬래시 등록 |
|
||||||
|
| `DEBUG` | 선택 | `true`면 명령어 오류 스택 로깅 |
|
||||||
|
| `REPLACETEXT` | 선택 | 치환 사전(JSON 배열). 기본 사전(`def_replaceObj`)과 병합 |
|
||||||
|
| `SIGNATURE_HOST` | 선택 | 시그니처 서버 `host:port`. 미설정 시 `192.168.10.5:2967` 기본값 |
|
||||||
|
| `TTSPATH` | 선택 | `Config.ttsPath` getter만 정의돼 있고 현재 호출처 없음. 향후 사용 대비 환경변수만 받아둠 |
|
||||||
|
|
||||||
|
## 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 의존성 설치
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 개발 (ts-node)
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 빌드 후 실행 (build 스크립트가 dist 디렉터리를 자동 생성)
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 전역 슬래시 명령어 등록 (배포 파이프라인)
|
||||||
|
npm run prod
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t tts_bot .
|
||||||
|
docker run --env-file .env -v $(pwd)/db:/app/db tts_bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Dockerfile은 `node:22-bookworm-slim` 기반이며 `ffmpeg`을 설치한다. (이전 `node:20-alpine` 은 musl 환경이라 `better-sqlite3` prebuilt 가 없어 node-gyp/python 빌드 의존성이 필요했고, `@discordjs/voice` 의 node>=22 요구도 충족하지 못해 변경했다.)
|
||||||
|
|
||||||
|
## 동작 흐름 요약
|
||||||
|
|
||||||
|
1. `messageCreate` → 등록된 TTS 채널이면 `TTSClient.tts()` 호출
|
||||||
|
2. 텍스트 전처리(URL/멘션/이모지 치환) → 시그니처 키워드 분할
|
||||||
|
3. 각 조각을 Chzzk TTS로 합성 또는 캐시된 시그니처 mp3 사용
|
||||||
|
4. mp3 Buffer 결합 → `Transcode`에서 PCM 변환 → `VoiceSession.player.play()`
|
||||||
|
5. 일정 시간 idle 시 세션 자동 종료, 사용자 채널 이동 시 따라감
|
||||||
|
|
||||||
|
## 참고
|
||||||
|
|
||||||
|
- 시그니처 서버 주소(`192.168.10.5:2967`)는 `SignatureClient.ts`에 하드코딩 — 환경에 맞게 수정 필요
|
||||||
|
- `commands/` 내 파일은 default export class 형태로 자동 로드되므로 새 명령어 추가 시 같은 패턴 유지
|
||||||
|
- 현재 `users` 테이블 스키마는 정의돼 있으나 코드에서 사용 흔적은 적음 (향후 확장 여지)
|
||||||
|
|||||||
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),
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -2,20 +2,20 @@
|
|||||||
"name": "tts_bot",
|
"name": "tts_bot",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "discord bot with typescript",
|
"description": "discord bot with typescript",
|
||||||
"homepage": "https://github.com/tkrmagid/bot.ts#readme",
|
"homepage": "https://git.tkrmagid.kr/tkrmagid/tts_bot",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/tkrmagid/bot.ts/issues"
|
"url": "https://git.tkrmagid.kr/tkrmagid/tts_bot/issues"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/tkrmagid/bot.ts.git"
|
"url": "git+https://git.tkrmagid.kr/tkrmagid/tts_bot.git"
|
||||||
},
|
},
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"author": "tkrmagid",
|
"author": "tkrmagid",
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "ts-cleaner && tsc",
|
"build": "node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && ts-cleaner && tsc",
|
||||||
"start": "node .",
|
"start": "node .",
|
||||||
"dev": "ts-node src/index.ts",
|
"dev": "ts-node src/index.ts",
|
||||||
"prod": "ts-node src/utils/Prod-commands.ts",
|
"prod": "ts-node src/utils/Prod-commands.ts",
|
||||||
@@ -41,5 +41,8 @@
|
|||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"tar": "^7.5.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,17 @@ export class Handler {
|
|||||||
public coolDown: Map<string, number> = new Map();
|
public coolDown: Map<string, number> = new Map();
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
const commandFiles = readdirSync(COMMANDS_PATH);
|
const commandFiles = readdirSync(COMMANDS_PATH).filter((f) => {
|
||||||
|
// ts-node로 dev 실행 시 .ts, 빌드 후엔 .js. 둘 다 허용하고
|
||||||
|
// 선언 파일(.d.ts)/소스맵(.js.map)은 제외한다.
|
||||||
|
if (f.endsWith(".d.ts")) return false;
|
||||||
|
if (f.endsWith(".js.map") || f.endsWith(".ts.map")) return false;
|
||||||
|
return f.endsWith(".ts") || f.endsWith(".js");
|
||||||
|
});
|
||||||
for (const commandFile of commandFiles) {
|
for (const commandFile of commandFiles) {
|
||||||
const command = new (require(COMMAND_PATH(commandFile)).default)() as Command;
|
const mod = require(COMMAND_PATH(commandFile));
|
||||||
|
if (typeof mod.default !== "function") continue; // not a command module
|
||||||
|
const command = new mod.default() as Command;
|
||||||
this.commands.set(command.metaData.name, command);
|
this.commands.set(command.metaData.name, command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import WebSocket from "ws";
|
|||||||
import ffmpeg from "fluent-ffmpeg";
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
|
import { Config } from "../utils/Config";
|
||||||
|
|
||||||
interface SignatureItem {
|
interface SignatureItem {
|
||||||
name: string[];
|
name: string[];
|
||||||
@@ -17,10 +18,13 @@ mkdirSync(filePath, { recursive: true });
|
|||||||
|
|
||||||
export class SignatureClient {
|
export class SignatureClient {
|
||||||
private readonly myClientId = "bot-"+Math.random().toString(36).slice(2, 9);
|
private readonly myClientId = "bot-"+Math.random().toString(36).slice(2, 9);
|
||||||
private readonly host = `192.168.10.5:2967`;
|
private readonly host = Config.signatureHost;
|
||||||
private readonly wsUrl = `ws://${this.host}?clientId=${this.myClientId}`;
|
private readonly wsUrl = `ws://${this.host}?clientId=${this.myClientId}`;
|
||||||
private ws = new WebSocket(this.wsUrl);
|
private ws!: WebSocket;
|
||||||
private signatureList: SignatureItem[] = [];
|
private signatureList: SignatureItem[] = [];
|
||||||
|
private reconnectDelayMs = 1000;
|
||||||
|
private readonly reconnectMaxMs = 30_000;
|
||||||
|
private reconnectTimer?: NodeJS.Timeout;
|
||||||
|
|
||||||
get list() {
|
get list() {
|
||||||
return this.signatureList;
|
return this.signatureList;
|
||||||
@@ -31,13 +35,24 @@ export class SignatureClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get regex() {
|
get regex() {
|
||||||
const escaped = [...this.nameSet].map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
const names = [...this.nameSet];
|
||||||
|
// 빈 목록이면 new RegExp("()", "g")가 모든 위치에서 매칭돼
|
||||||
|
// text.split() 결과가 글자 단위로 쪼개진다. 절대 매칭되지 않는
|
||||||
|
// 패턴을 돌려줘 split 호출자가 원문을 그대로 받게 한다.
|
||||||
|
if (names.length === 0) return /$^/g;
|
||||||
|
const escaped = names.map(k => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||||
return new RegExp(`(${escaped.join("|")})`, "g");
|
return new RegExp(`(${escaped.join("|")})`, "g");
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private connect() {
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
this.ws.on("open", () => {
|
this.ws.on("open", () => {
|
||||||
Logger.ready("[WS] 서버 연결 성공");
|
Logger.ready("[WS] 서버 연결 성공");
|
||||||
|
this.reconnectDelayMs = 1000; // reset backoff on successful connect
|
||||||
const requestMessage = JSON.stringify({ type: "GET_LIST" });
|
const requestMessage = JSON.stringify({ type: "GET_LIST" });
|
||||||
this.ws.send(requestMessage);
|
this.ws.send(requestMessage);
|
||||||
});
|
});
|
||||||
@@ -54,12 +69,27 @@ export class SignatureClient {
|
|||||||
});
|
});
|
||||||
this.ws.on("close", () => {
|
this.ws.on("close", () => {
|
||||||
Logger.info("[WS] 서버 연결 종료");
|
Logger.info("[WS] 서버 연결 종료");
|
||||||
|
this.scheduleReconnect();
|
||||||
});
|
});
|
||||||
this.ws.on("error", (error) => {
|
this.ws.on("error", (error) => {
|
||||||
Logger.error(`[WS] 오류 발생: ${String(error)}`);
|
Logger.error(`[WS] 오류 발생: ${String(error)}`);
|
||||||
|
// 'error' is typically followed by 'close' on the ws lib, but call
|
||||||
|
// out reconnect anyway in case the socket never closes cleanly.
|
||||||
|
try { this.ws.terminate(); } catch {}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleReconnect() {
|
||||||
|
if (this.reconnectTimer) return; // already scheduled
|
||||||
|
const delay = this.reconnectDelayMs;
|
||||||
|
this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, this.reconnectMaxMs);
|
||||||
|
Logger.info(`[WS] ${delay}ms 후 재연결 시도`);
|
||||||
|
this.reconnectTimer = setTimeout(() => {
|
||||||
|
this.reconnectTimer = undefined;
|
||||||
|
this.connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
async changeVolume(buffer: Buffer, volume: number) {
|
async changeVolume(buffer: Buffer, volume: number) {
|
||||||
try {
|
try {
|
||||||
const inputStream = Readable.from(buffer);
|
const inputStream = Readable.from(buffer);
|
||||||
|
|||||||
@@ -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.getSorce(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,8 +97,20 @@ export class TTSClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async getSorce(text: string): Promise<Buffer | null> {
|
// DB 에 저장된 유저별 목소리를 가져온다. 없거나 알 수 없는 값이면 기본값.
|
||||||
const parts = text.split(signature.regex);
|
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을 건너뛰고 원문을 한 번에 합성한다.
|
||||||
|
// (SignatureClient.regex가 빈 목록일 때 never-match 패턴을 돌려주지만,
|
||||||
|
// 소비자 측에서도 명시적으로 가드해 의도를 분명히 한다.)
|
||||||
|
const names = signature.nameSet;
|
||||||
|
const parts = names.size === 0 ? [text] : text.split(signature.regex);
|
||||||
const bufferList: Buffer[] = [];
|
const bufferList: Buffer[] = [];
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
@@ -102,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,11 +137,11 @@ export class TTSClient {
|
|||||||
if (text.length === 0) return "파일";
|
if (text.length === 0) return "파일";
|
||||||
return text
|
return text
|
||||||
.replace(URL_RE, (u) => labelForUrl(u) ?? u)
|
.replace(URL_RE, (u) => labelForUrl(u) ?? u)
|
||||||
.replace(/\<\@\!?[(0-9)]{18}\>/g, (t) => {
|
.replace(/<@!?(\d{17,20})>/g, (_t, id: string) => {
|
||||||
const member = guild.members.cache.get(t.replace(/[^0-9]/g,""));
|
const member = guild.members.cache.get(id);
|
||||||
return member?.nickname ?? member?.user.username ?? "유저";
|
return member?.nickname ?? member?.user.username ?? "유저";
|
||||||
})
|
})
|
||||||
.replace(/\<a?\:.*\:[(0-9)]{18}\>/g, () => {
|
.replace(/<a?:[^:]+:\d{17,20}>/g, () => {
|
||||||
return "이모티콘";
|
return "이모티콘";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ export class VoiceSession {
|
|||||||
}, this.endTimeMs);
|
}, this.endTimeMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 TTS가 들어오면 이전 재생을 의도적으로 interrupt한다 (큐잉 없음).
|
||||||
|
* 다중 사용자 환경에서 모든 메시지를 큐로 쌓으면 자기 발화가 수십 초
|
||||||
|
* 밀려 채팅 맥락과 어긋나므로, "최신 발화 우선, 앞건 잘림"을 정책으로
|
||||||
|
* 채택했다. generation 카운터는 같은 메시지에 대해 한 번만 play 되도록
|
||||||
|
* 보호하는 용도이고, 이전 mp3가 잘리는 동작 자체는 설계 의도이다.
|
||||||
|
*/
|
||||||
public async play(mp3Buf: Buffer) {
|
public async play(mp3Buf: Buffer) {
|
||||||
if (this.isSessionEnd) return;
|
if (this.isSessionEnd) return;
|
||||||
const myGen = ++this.generation;
|
const myGen = ++this.generation;
|
||||||
|
|||||||
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,7 @@ export const Config = {
|
|||||||
nid_ses: process.env.CHZZK_NID_SES?.trim(),
|
nid_ses: process.env.CHZZK_NID_SES?.trim(),
|
||||||
},
|
},
|
||||||
_ttsPath: process.env.TTSPATH?.trim(),
|
_ttsPath: process.env.TTSPATH?.trim(),
|
||||||
|
_signatureHost: process.env.SIGNATURE_HOST?.trim() || "192.168.10.5:2967",
|
||||||
debug: process.env.DEBUG?.trim()?.toLocaleLowerCase() === "true",
|
debug: process.env.DEBUG?.trim()?.toLocaleLowerCase() === "true",
|
||||||
dev: process.env.DEV?.trim()?.toLocaleLowerCase() === "true",
|
dev: process.env.DEV?.trim()?.toLocaleLowerCase() === "true",
|
||||||
replaceObj: { ...def_replaceObj, ...JSON.parse(process.env.REPLACETEXT?.trim() || "[{}]")[0] },
|
replaceObj: { ...def_replaceObj, ...JSON.parse(process.env.REPLACETEXT?.trim() || "[{}]")[0] },
|
||||||
@@ -48,6 +49,9 @@ export const Config = {
|
|||||||
get ttsPath() {
|
get ttsPath() {
|
||||||
if (!this._ttsPath) throw new ReferenceError("TTSPATH is missing");
|
if (!this._ttsPath) throw new ReferenceError("TTSPATH is missing");
|
||||||
return this._ttsPath;
|
return this._ttsPath;
|
||||||
|
},
|
||||||
|
get signatureHost() {
|
||||||
|
return this._signatureHost;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
@@ -55,7 +64,7 @@ const stmt = {
|
|||||||
update: (data: UserType) => {
|
update: (data: UserType) => {
|
||||||
const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id");
|
const keys = Object.keys(data).filter(k => k !== "guild_id" && k !== "id");
|
||||||
if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
if (keys.length === 0) throw new Error("update: 키1개는 있어야함");
|
||||||
return database.prepare(`UPDATE guilds SET ${
|
return database.prepare(`UPDATE users SET ${
|
||||||
keys.map(k => `${k} = @${k}`).join(", ")
|
keys.map(k => `${k} = @${k}`).join(", ")
|
||||||
} WHERE guild_id = @guild_id AND id = @id`).run(data);
|
} WHERE guild_id = @guild_id AND id = @id`).run(data);
|
||||||
},
|
},
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,9 +1,23 @@
|
|||||||
import colors from "colors/safe";
|
import colors from "colors/safe";
|
||||||
|
|
||||||
|
// 컨테이너/호스트의 TZ 설정과 무관하게 항상 KST(Asia/Seoul) 기준으로
|
||||||
|
// 'YY-MM-DD HH:MM:SS' 형식의 타임스탬프를 만든다. 기존 구현은
|
||||||
|
// setHours(+9)로 수동 보정해 서버 TZ가 UTC가 아닐 때 시간이 어긋났다.
|
||||||
|
const KST_FORMATTER = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "Asia/Seoul",
|
||||||
|
year: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
|
||||||
export const Timestamp = () => {
|
export const Timestamp = () => {
|
||||||
const Now = new Date();
|
const parts = KST_FORMATTER.formatToParts(new Date());
|
||||||
Now.setHours(Now.getHours() + 9);
|
const get = (type: string) => parts.find(p => p.type === type)?.value ?? "00";
|
||||||
return Now.toISOString().replace('T', ' ').substring(0, 19).slice(2);
|
return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
|
type logType = "log" | "info" | "warn" | "error" | "debug" | "ready" | "slash";
|
||||||
|
|||||||
@@ -6,6 +6,18 @@ import { Logger } from "./Logger";
|
|||||||
async function main() {
|
async function main() {
|
||||||
const rest = new REST({ version: "10" }).setToken(Config.token);
|
const rest = new REST({ version: "10" }).setToken(Config.token);
|
||||||
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
|
const body = Array.from(handler.commands.values().filter(cmd => cmd.visible).map(cmd => cmd.metaData));
|
||||||
|
|
||||||
|
// 개발 모드에서는 Config.guildId 길드에 따로 등록된 슬래시가 남아
|
||||||
|
// 전역 명령어와 디스코드에 두 번 보이는 경우가 생긴다.
|
||||||
|
// 전역 등록 전에 해당 길드의 명령어를 전부 비워둔다.
|
||||||
|
if (Config.dev) {
|
||||||
|
await rest.put(
|
||||||
|
Routes.applicationGuildCommands(Config.appId, Config.guildId),
|
||||||
|
{ body: [] },
|
||||||
|
);
|
||||||
|
Logger.ready(`개발 길드(${Config.guildId}) 슬래시 전체 삭제 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
// 전역 등록 (권장: 배포 파이프라인에서만 실행)
|
// 전역 등록 (권장: 배포 파이프라인에서만 실행)
|
||||||
await rest.put(Routes.applicationCommands(Config.appId), { body });
|
await rest.put(Routes.applicationCommands(Config.appId), { body });
|
||||||
Logger.ready(`전역 슬래시 등록 요청 완료: ${body.length}개`);
|
Logger.ready(`전역 슬래시 등록 요청 완료: ${body.length}개`);
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import ffmpegPath from "ffmpeg-static";
|
|||||||
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { AudioResource, createAudioResource, StreamType } from "@discordjs/voice";
|
import { AudioResource, createAudioResource, StreamType } from "@discordjs/voice";
|
||||||
|
import { Logger } from "./Logger";
|
||||||
|
|
||||||
/** MP3 Buffer -> PCM(s16le 48k 2ch) Readable stream */
|
/** MP3 Buffer -> PCM(s16le 48k 2ch) Readable stream */
|
||||||
function mp3BufferToPcmStream(mp3Buf: Buffer): Readable {
|
function mp3BufferToPcmStream(mp3Buf: Buffer): Readable {
|
||||||
if (!ffmpegPath) throw new Error("ffmpeg-static 경로 확인 실패");
|
if (!ffmpegPath) throw new Error("ffmpeg-static 경로 확인 실패");
|
||||||
const ff: ChildProcessWithoutNullStreams = spawn(ffmpegPath, [
|
const ff: ChildProcessWithoutNullStreams = spawn(ffmpegPath, [
|
||||||
"-loglevel","quiet","-hide_banner",
|
"-loglevel","error","-hide_banner",
|
||||||
"-i","pipe:0", // stdin으로 mp3
|
"-i","pipe:0", // stdin으로 mp3
|
||||||
"-f","s16le", // raw PCM
|
"-f","s16le", // raw PCM
|
||||||
"-ar","48000", // 48k
|
"-ar","48000", // 48k
|
||||||
@@ -15,14 +16,20 @@ function mp3BufferToPcmStream(mp3Buf: Buffer): Readable {
|
|||||||
"pipe:1" // stdout으로 PCM
|
"pipe:1" // stdout으로 PCM
|
||||||
], { stdio: ["pipe","pipe","pipe"] });
|
], { stdio: ["pipe","pipe","pipe"] });
|
||||||
|
|
||||||
// 입력 밀어넣고 닫기
|
// ffmpeg가 비정상 종료해도 부모가 죽지 않도록 모든 핸들에 에러 핸들러를 단다.
|
||||||
// ff.stdin.write(mp3Buf);
|
// stdin EPIPE, stdout 소비 실패 등을 조용히 흘려 보내고 자식 프로세스를
|
||||||
// ff.stdin.end();
|
// 강제 종료해 leak을 막는다.
|
||||||
|
const killFf = () => { try { ff.kill("SIGKILL"); } catch {} };
|
||||||
|
ff.on("error", (e) => { Logger.error("ffmpeg spawn error: "+String(e)); killFf(); });
|
||||||
|
ff.stderr.on("data", (chunk: Buffer) => {
|
||||||
|
const msg = chunk.toString().trim();
|
||||||
|
if (msg) Logger.warn("[ffmpeg] "+msg);
|
||||||
|
});
|
||||||
|
ff.stdin.on("error", () => { /* EPIPE on early close */ });
|
||||||
|
ff.stdout.on("error", () => { /* downstream gone */ });
|
||||||
|
|
||||||
Readable.from(mp3Buf).pipe(ff.stdin);
|
Readable.from(mp3Buf).pipe(ff.stdin).on("error", () => {});
|
||||||
|
|
||||||
// ffmpeg stdout(PCM)을 그대로 리턴
|
|
||||||
// (에러 로그가 필요하면 ff.stderr 'data' 핸들링 추가)
|
|
||||||
return ff.stdout;
|
return ff.stdout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"baseUrl": "./src",
|
"baseUrl": "./src",
|
||||||
|
"rootDir": "./src",
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"removeComments": true,
|
"removeComments": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user