Compare commits

..

3 Commits

Author SHA1 Message Date
paring 3c1a66eec1
fix: filename 2023-06-29 01:42:06 +09:00
paring 8b75aef1fe
feat: auto merge 2023-06-29 01:39:54 +09:00
paring a7dd7869f2
feat: merge audio 2023-06-29 01:11:13 +09:00
14 changed files with 368 additions and 35 deletions

1
.gitignore vendored
View File

@ -133,3 +133,4 @@ dist
config.toml
recordings
.idea
prisma/data.db*

View File

@ -4,7 +4,8 @@
"description": "",
"main": "dist/index.js",
"scripts": {
"dev": "tsx src/index.ts"
"dev": "tsx src/index.ts",
"merge": "tsx src/merge.ts"
},
"keywords": [],
"author": "",
@ -23,10 +24,12 @@
"@discordjs/rest": "^1.7.1",
"@discordjs/voice": "^0.16.0",
"@discordjs/ws": "^0.8.3",
"@prisma/client": "4.16.1",
"discord-api-types": "^0.37.46",
"libsodium-wrappers": "^0.7.11",
"node-crc": "1.3.2",
"prism-media": "2.0.0-alpha.0",
"prisma": "^4.16.1",
"toml": "^3.0.0"
}
}

View File

@ -23,6 +23,9 @@ dependencies:
'@discordjs/ws':
specifier: ^0.8.3
version: 0.8.3
'@prisma/client':
specifier: 4.16.1
version: 4.16.1(prisma@4.16.1)
discord-api-types:
specifier: ^0.37.46
version: 0.37.46
@ -35,6 +38,9 @@ dependencies:
prism-media:
specifier: 2.0.0-alpha.0
version: 2.0.0-alpha.0
prisma:
specifier: ^4.16.1
version: 4.16.1
toml:
specifier: ^3.0.0
version: 3.0.0
@ -379,6 +385,29 @@ packages:
dev: true
optional: true
/@prisma/client@4.16.1(prisma@4.16.1):
resolution: {integrity: sha512-CoDHu7Bt+NuDo40ijoeHP79EHtECsPBTy3yte5Yo3op8TqXt/kV0OT5OrsWewKvQGKFMHhYQ+ePed3zzjYdGAw==}
engines: {node: '>=14.17'}
requiresBuild: true
peerDependencies:
prisma: '*'
peerDependenciesMeta:
prisma:
optional: true
dependencies:
'@prisma/engines-version': 4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c
prisma: 4.16.1
dev: false
/@prisma/engines-version@4.16.0-66.b20ead4d3ab9e78ac112966e242ded703f4a052c:
resolution: {integrity: sha512-tMWAF/qF00fbUH1HB4Yjmz6bjh7fzkb7Y3NRoUfMlHu6V+O45MGvqwYxqwBjn1BIUXkl3r04W351D4qdJjrgvA==}
dev: false
/@prisma/engines@4.16.1:
resolution: {integrity: sha512-gpZG0kGGxfemgvK/LghHdBIz+crHkZjzszja94xp4oytpsXrgt/Ice82MvPsWMleVIniKuARrowtsIsim0PFJQ==}
requiresBuild: true
dev: false
/@sapphire/async-queue@1.5.0:
resolution: {integrity: sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
@ -803,6 +832,15 @@ packages:
duplex-child-process: 1.0.1
dev: false
/prisma@4.16.1:
resolution: {integrity: sha512-C2Xm7yxHxjFjjscBEW4tmoraPHH/Vyu/A0XABdbaFtoiOZARsxvOM7rwc2iZ0qVxbh0bGBGBWZUSXO/52/nHBQ==}
engines: {node: '>=14.17'}
hasBin: true
requiresBuild: true
dependencies:
'@prisma/engines': 4.16.1
dev: false
/readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}

View File

@ -0,0 +1,15 @@
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"channel" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "File" (
"id" TEXT NOT NULL PRIMARY KEY,
"filename" TEXT NOT NULL,
"timestamp" BIGINT NOT NULL,
"sessionId" TEXT,
CONSTRAINT "File_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);

View File

@ -0,0 +1,20 @@
/*
Warnings:
- Made the column `sessionId` on table `File` required. This step will fail if there are existing NULL values in that column.
*/
-- RedefineTables
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_File" (
"id" TEXT NOT NULL PRIMARY KEY,
"filename" TEXT NOT NULL,
"timestamp" BIGINT NOT NULL,
"sessionId" TEXT NOT NULL,
CONSTRAINT "File_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_File" ("filename", "id", "sessionId", "timestamp") SELECT "filename", "id", "sessionId", "timestamp" FROM "File";
DROP TABLE "File";
ALTER TABLE "new_File" RENAME TO "File";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

27
prisma/schema.prisma Normal file
View File

@ -0,0 +1,27 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:data.db"
}
model Session {
id String @id @default(uuid())
createdAt DateTime @default(now())
channel String
files File[]
}
model File {
id String @id @default(uuid())
filename String
timestamp BigInt
session Session @relation(fields: [sessionId], references: [id], onDelete: Cascade)
sessionId String
}

View File

@ -4,6 +4,7 @@ import { Client, GatewayIntentBits } from "@discordjs/core"
import { config } from "./config.js"
import { VoiceStateManager } from "./utils/wrapper/VoiceState.js"
import { RecordingSessionManager } from "./utils/sessions.js"
import { PrismaClient } from "@prisma/client"
export const rest = new REST({ version: "10" }).setToken(config.token)
@ -20,3 +21,5 @@ export const client = new Client({ rest, gateway })
export const voiceStateManager = new VoiceStateManager()
export const sessionManager = new RecordingSessionManager()
export const db = new PrismaClient()

View File

@ -34,7 +34,7 @@ export const startCommand = async ({
content: "먼저 음성 채널에 들어가주세요!",
})
sessionManager.addSession(data.guild_id, vc, shardId)
await sessionManager.addSession(data.guild_id, vc, data.channel_id, shardId)
await api.interactions.reply(data.id, data.token, {
content: "냥냥",

View File

@ -1,4 +1,10 @@
import { client, gateway, sessionManager, voiceStateManager } from "./api.js"
import {
client,
db,
gateway,
sessionManager,
voiceStateManager,
} from "./api.js"
import {
ActivityType,
@ -13,6 +19,10 @@ import { startCommand } from "./commands/start.js"
import { config } from "./config.js"
import { syncCommands } from "./utils/commands.js"
import { VoiceState } from "./utils/wrapper/VoiceState.js"
import { isMe } from "./utils/appid.js"
import { mergeAudio } from "./utils/merge.js"
import { readFile, rm } from "fs/promises"
import path from "path"
process.on("uncaughtException", (error) => console.error(error))
process.on("unhandledRejection", (error) => console.error(error))
@ -82,20 +92,100 @@ client.on(GatewayDispatchEvents.InteractionCreate, async (args) => {
}
})
client.on(GatewayDispatchEvents.VoiceStateUpdate, async (args) => {
if (args.data.guild_id) {
const adapter = sessionManager.adapters.get(args.data.guild_id)
if (adapter) adapter.onVoiceStateUpdate(args.data)
const guild = voiceStateManager.guilds.get(args.data.guild_id)
client.on(GatewayDispatchEvents.VoiceStateUpdate, async ({ data, api }) => {
if (data.guild_id) {
const adapter = sessionManager.adapters.get(data.guild_id)
if (adapter) adapter.onVoiceStateUpdate(data)
const guild = voiceStateManager.guilds.get(data.guild_id)
if (guild) {
const state = guild.get(args.data.user_id)
const state = guild.get(data.user_id)
if (state) {
state.update(args.data)
state.update(data)
} else {
guild.set(
args.data.user_id,
new VoiceState(args.data, args.data.guild_id)
guild.set(data.user_id, new VoiceState(data, data.guild_id))
}
const session = sessionManager.sessionMap.get(data.guild_id)
if (session) {
let counter = 0
let hasMe = false
for (const state of guild.values()) {
if (state.channelId !== session.voiceChannelId) continue
if (isMe(state.id)) {
hasMe = true
continue
}
counter++
}
if (counter === 0 || !hasMe) {
session.vc.destroy()
sessionManager.removeSession(data.guild_id)
await api.channels.createMessage(session.textChannelId, {
content: `녹화가 종료되었어요! 오디오 파일 합치는 중...`,
})
setTimeout(async () => {
await mergeAudio(session.id, async (code) => {
if (code === 0) {
let succeeded = false
try {
await api.channels.createMessage(
session.textChannelId,
{
content:
"오디오 파일 합치기가 완료되었어요!",
files: [
{
contentType: "audio/ogg",
name: `${session.id}.ogg`,
data: await readFile(
path.resolve(
"recordings",
"final",
`${session.id}.ogg`
)
),
},
],
}
)
succeeded = true
} catch {
await api.channels.createMessage(
session.textChannelId,
{
content: `오디오 파일 합치기가 완료되었...지만 파일 업로드에 실패했어요.\n파일 위치: ${path.join(
"recordings",
"final",
`${session.id}.ogg`
)}`,
}
)
}
if (succeeded) {
await rm(
path.resolve("recordings", session.id),
{ recursive: true, force: true }
)
await db.session.delete({
where: { id: session.id },
})
}
} else {
await api.channels.createMessage(
session.textChannelId,
{
content: `ffmpeg 명령어 실행에 실패했어요! (exit code ${code}) ID: ${session.id}`,
}
)
}
})
}, 1000)
}
}
}
}

12
src/merge.ts Normal file
View File

@ -0,0 +1,12 @@
import { db } from "./api.js"
import { mergeAudio } from "./utils/merge.js"
const id = process.argv[2]
const session = await db.session.findUnique({ where: { id } })
if (!session) {
throw new Error("session not found")
}
await mergeAudio(id)

View File

@ -1,3 +1,8 @@
import { config } from "../config.js"
export const getAppIdFromToken = (token: string) => {
return atob(token.split(".")[0])
}
export const isMe = (userId: string) =>
userId === getAppIdFromToken(config.token)

70
src/utils/merge.ts Normal file
View File

@ -0,0 +1,70 @@
import path from "path"
import { db } from "../api.js"
import { spawn } from "child_process"
import { existsSync, mkdirSync } from "fs"
export const mergeAudio = async (
id: string,
callback?: (code: number | null) => void
) => {
const finalDir = "recordings/final"
const sourceDir = path.resolve("recordings", id)
if (!existsSync(finalDir)) {
mkdirSync(finalDir, { recursive: true })
}
const files = await db.file.findMany({
where: {
session: {
id,
},
},
})
let inputs: string[] = []
let filter: string[] = []
let trackNames: string[] = []
let counter = 0
for (const file of files) {
const filePath = path.join(sourceDir, file.filename)
const index = counter++
const trackName = `[c${index}]`
inputs.push("-i", filePath)
filter.push(
`[${index}:a] adelay=${file.timestamp}|${file.timestamp} ${trackName}`
)
trackNames.push(trackName)
}
filter.push(
`${trackNames.join(" ")} amix=inputs=${
trackNames.length
}:normalize=0 [out]`
)
const args = [
...inputs,
"-filter_complex",
filter.join(";"),
"-map",
"[out]",
"-async",
"1",
"-y",
path.join(finalDir, `${id}.ogg`),
]
const p = spawn("ffmpeg", args).on("exit", (code) => {
console.log("ffmpeg exited with code", code)
callback?.(code)
})
p.stderr.pipe(process.stderr)
}

View File

@ -1,5 +1,5 @@
import { Collection } from "@discordjs/collection"
import { gateway, voiceStateManager } from "../api.js"
import { db, gateway, voiceStateManager } from "../api.js"
import {
AudioReceiveStream,
DiscordGatewayAdapterLibraryMethods,
@ -11,9 +11,9 @@ import { Snowflake } from "discord-api-types/v10"
import * as prism from "prism-media"
import { pipeline } from "stream"
import { createWriteStream, existsSync, mkdirSync } from "fs"
import { getAppIdFromToken } from "./appid.js"
import { config } from "../config.js"
import path from "path"
import { isMe } from "./appid.js"
import { exec, spawn } from "child_process"
export class RecordingSessionManager {
sessionMap = new Collection<Snowflake, RecordingSession>()
@ -23,11 +23,16 @@ export class RecordingSessionManager {
return this.sessionMap.has(guild)
}
addSession(guild: Snowflake, channel: Snowflake, shardId: number) {
async addSession(
guild: Snowflake,
voiceChannel: Snowflake,
textChannel: Snowflake,
shardId: number
) {
const session = new RecordingSession(
joinVoiceChannel({
guildId: guild,
channelId: channel,
channelId: voiceChannel,
adapterCreator: (methods) => {
this.adapters.set(guild, methods)
return {
@ -43,22 +48,46 @@ export class RecordingSessionManager {
selfDeaf: false,
selfMute: true,
debug: true,
})
}),
voiceChannel,
textChannel
)
await session.init()
this.sessionMap.set(guild, session)
}
removeSession(guild: Snowflake) {
const session = this.sessionMap.get(guild)
if (session) session.shutdown()
this.sessionMap.delete(guild)
}
}
export class RecordingSession {
id: string
streams = new Collection<Snowflake, UserStream>()
startedAt = Date.now()
id!: string
constructor(public vc: VoiceConnection) {
this.id = Date.now().toString()
vc.receiver.speaking.on("start", (userId) => this.addReceiver(userId))
vc.receiver.speaking.on("end", (userId) => this.removeReceiver(userId))
constructor(
public vc: VoiceConnection,
public voiceChannelId: Snowflake,
public textChannelId: Snowflake
) {}
async init() {
const result = await db.session.create({
data: {
id: this.id,
channel: this.voiceChannelId,
},
})
this.id = result.id
this.receiver.speaking.on("start", (userId) => this.addReceiver(userId))
this.receiver.speaking.on("end", (userId) =>
this.removeReceiver(userId)
)
}
get receiver() {
@ -66,11 +95,9 @@ export class RecordingSession {
}
addReceiver(userId: string) {
if (userId === getAppIdFromToken(config.token)) return
if (isMe(userId)) return
if (this.streams.has(userId)) return
console.log("Starting record:", userId)
const receiver = this.receiver.subscribe(userId)
this.streams.set(userId, new UserStream(this, receiver, userId))
@ -80,11 +107,12 @@ export class RecordingSession {
const stream = this.streams.get(userId)
if (stream) {
console.log("Stopping record:", userId)
stream.subscription.push(null)
this.streams.delete(userId)
}
}
async shutdown() {}
}
class UserStream {
@ -105,7 +133,9 @@ class UserStream {
},
})
const dir = path.resolve("recordings", session.id, userId)
const dir = path.resolve("recordings", session.id)
const timestamp = Date.now() - session.startedAt
const file = `${userId}-${timestamp}.ogg`
if (!existsSync(dir)) {
mkdirSync(dir, {
@ -113,20 +143,36 @@ class UserStream {
})
}
const filename = path.join(dir, `${Date.now() - session.startedAt}.ogg`)
const filename = path.join(dir, file)
console.log(`Recording ${filename} started`)
const outputStream = createWriteStream(filename)
pipeline(subscription, this.oggStream, outputStream, (err) => {
pipeline(subscription, this.oggStream, outputStream, async (err) => {
outputStream.end()
if (err) {
console.log(`Recording ${userId} ended with error: ${err}`)
console.log(`Recording ${filename} ended with error: ${err}`)
subscription.destroy(err)
return
}
console.log(`Recording ${userId} ended`)
await db.file
.create({
data: {
session: {
connect: {
id: session.id,
},
},
filename: file,
timestamp,
},
})
.catch(console.error)
console.log(`Recording ${filename} ended`)
})
}
}