feat: merge audio
							parent
							
								
									6dece81a93
								
							
						
					
					
						commit
						a7dd7869f2
					
				|  | @ -133,3 +133,4 @@ dist | |||
| config.toml | ||||
| recordings | ||||
| .idea | ||||
| prisma/data.db* | ||||
|  | @ -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" | ||||
|   } | ||||
| } | ||||
|  |  | |||
|  | @ -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'} | ||||
|  |  | |||
|  | @ -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 | ||||
| ); | ||||
|  | @ -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; | ||||
|  | @ -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" | ||||
|  | @ -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 | ||||
| } | ||||
|  | @ -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() | ||||
|  |  | |||
|  | @ -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: "냥냥", | ||||
|  |  | |||
							
								
								
									
										44
									
								
								src/index.ts
								
								
								
								
							
							
						
						
									
										44
									
								
								src/index.ts
								
								
								
								
							|  | @ -13,6 +13,7 @@ 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" | ||||
| 
 | ||||
| process.on("uncaughtException", (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) => { | ||||
| 	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: `${session.id}`, | ||||
| 					}) | ||||
| 				} | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  |  | |||
|  | @ -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) | ||||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  | @ -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,48 @@ 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, | ||||
| 		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() | ||||
| 		vc.receiver.speaking.on("start", (userId) => this.addReceiver(userId)) | ||||
| 		vc.receiver.speaking.on("end", (userId) => this.removeReceiver(userId)) | ||||
| 		this.receiver.speaking.on("start", (userId) => this.addReceiver(userId)) | ||||
| 		this.receiver.speaking.on("end", (userId) => | ||||
| 			this.removeReceiver(userId) | ||||
| 		) | ||||
| 	} | ||||
| 
 | ||||
| 	get receiver() { | ||||
|  | @ -66,11 +97,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 +109,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 +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)) { | ||||
| 			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) | ||||
| 
 | ||||
| 		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`) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue