feat: merge audio

master
paring 2023-06-29 01:11:08 +09:00
parent 6dece81a93
commit a7dd7869f2
Signed by: pikokr
GPG Key ID: 8C7ABCEF704FD728
14 changed files with 295 additions and 33 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -23,6 +23,9 @@ dependencies:
'@discordjs/ws': '@discordjs/ws':
specifier: ^0.8.3 specifier: ^0.8.3
version: 0.8.3 version: 0.8.3
'@prisma/client':
specifier: 4.16.1
version: 4.16.1(prisma@4.16.1)
discord-api-types: discord-api-types:
specifier: ^0.37.46 specifier: ^0.37.46
version: 0.37.46 version: 0.37.46
@ -35,6 +38,9 @@ dependencies:
prism-media: prism-media:
specifier: 2.0.0-alpha.0 specifier: 2.0.0-alpha.0
version: 2.0.0-alpha.0 version: 2.0.0-alpha.0
prisma:
specifier: ^4.16.1
version: 4.16.1
toml: toml:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0 version: 3.0.0
@ -379,6 +385,29 @@ packages:
dev: true dev: true
optional: 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: /@sapphire/async-queue@1.5.0:
resolution: {integrity: sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==} resolution: {integrity: sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==}
engines: {node: '>=v14.0.0', npm: '>=7.0.0'} engines: {node: '>=v14.0.0', npm: '>=7.0.0'}
@ -803,6 +832,15 @@ packages:
duplex-child-process: 1.0.1 duplex-child-process: 1.0.1
dev: false 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: /readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} 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 { config } from "./config.js"
import { VoiceStateManager } from "./utils/wrapper/VoiceState.js" import { VoiceStateManager } from "./utils/wrapper/VoiceState.js"
import { RecordingSessionManager } from "./utils/sessions.js" import { RecordingSessionManager } from "./utils/sessions.js"
import { PrismaClient } from "@prisma/client"
export const rest = new REST({ version: "10" }).setToken(config.token) 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 voiceStateManager = new VoiceStateManager()
export const sessionManager = new RecordingSessionManager() export const sessionManager = new RecordingSessionManager()
export const db = new PrismaClient()

View File

@ -34,7 +34,7 @@ export const startCommand = async ({
content: "먼저 음성 채널에 들어가주세요!", 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, { await api.interactions.reply(data.id, data.token, {
content: "냥냥", content: "냥냥",

View File

@ -13,6 +13,7 @@ import { startCommand } from "./commands/start.js"
import { config } from "./config.js" import { config } from "./config.js"
import { syncCommands } from "./utils/commands.js" import { syncCommands } from "./utils/commands.js"
import { VoiceState } from "./utils/wrapper/VoiceState.js" import { VoiceState } from "./utils/wrapper/VoiceState.js"
import { isMe } from "./utils/appid.js"
process.on("uncaughtException", (error) => console.error(error)) process.on("uncaughtException", (error) => console.error(error))
process.on("unhandledRejection", (error) => console.error(error)) process.on("unhandledRejection", (error) => console.error(error))
@ -82,20 +83,41 @@ client.on(GatewayDispatchEvents.InteractionCreate, async (args) => {
} }
}) })
client.on(GatewayDispatchEvents.VoiceStateUpdate, async (args) => { client.on(GatewayDispatchEvents.VoiceStateUpdate, async ({ data, api }) => {
if (args.data.guild_id) { if (data.guild_id) {
const adapter = sessionManager.adapters.get(args.data.guild_id) const adapter = sessionManager.adapters.get(data.guild_id)
if (adapter) adapter.onVoiceStateUpdate(args.data) if (adapter) adapter.onVoiceStateUpdate(data)
const guild = voiceStateManager.guilds.get(args.data.guild_id) const guild = voiceStateManager.guilds.get(data.guild_id)
if (guild) { if (guild) {
const state = guild.get(args.data.user_id) const state = guild.get(data.user_id)
if (state) { if (state) {
state.update(args.data) state.update(data)
} else { } else {
guild.set( guild.set(data.user_id, new VoiceState(data, data.guild_id))
args.data.user_id, }
new VoiceState(args.data, args.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: `${session.id}`,
})
}
} }
} }
} }

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) => { export const getAppIdFromToken = (token: string) => {
return atob(token.split(".")[0]) return atob(token.split(".")[0])
} }
export const isMe = (userId: string) =>
userId === getAppIdFromToken(config.token)

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

@ -0,0 +1,65 @@
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) => {
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)
})
p.stderr.pipe(process.stderr)
}

View File

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