Compare commits

...

10 Commits

Author SHA1 Message Date
MythEclipse
f9a4b4a92d feat: add muxer job queue with SQLite integration and metrics tracking 2026-05-13 17:06:22 +07:00
MythEclipse
978c2c468d feat: enhance configuration management and add error handling 2026-05-13 16:57:07 +07:00
MythEclipse
673a06376c feat: implement graceful shutdown process for bot
- Added graceful shutdown functionality to handle SIGINT, SIGTERM, uncaught exceptions, and unhandled promise rejections.
- Integrated stopRecording and pause functionality during shutdown.
- Enhanced logging for shutdown steps and error handling.
- Updated package.json to include pino-pretty for improved logging output.
2026-05-13 16:32:14 +07:00
MythEclipse
3ae28157a3 feat: implement logging and retry mechanism with pino and p-retry 2026-05-13 16:25:01 +07:00
MythEclipse
9497e721e0 docs: add env example configuration 2026-05-13 16:05:56 +07:00
MythEclipse
7a5ac2e34a refactor: make hardcoded values configurable via env vars 2026-05-13 16:05:19 +07:00
MythEclipse
48cc83f624 chore: add dist/ to .gitignore 2026-05-13 16:03:06 +07:00
MythEclipse
b4302585c8 chore: add build script 2026-05-13 16:02:16 +07:00
MythEclipse
80081a6f62 refactor: modularize recorder orchestration 2026-05-13 16:00:21 +07:00
MythEclipse
f655daa0c7 style: format recorder modules 2026-05-13 15:56:56 +07:00
21 changed files with 885 additions and 1899 deletions

35
.env.example Normal file
View File

@@ -0,0 +1,35 @@
# Discord Bot Configuration
DISCORD_TOKEN=your_bot_token_here
VOICE_CHANNEL_ID=your_voice_channel_id_here
GUILD_ID=your_guild_id_here
# Recording Configuration
RECORDINGS_DIR=./recordings
RECORDING_SEGMENT_MS=5000
VERBOSE=false
# Decoder Configuration
DECODER_ROTATE_MS=5000
DECODER_COOLDOWN_MS=30000
# Audio Configuration
AUDIO_STREAM_SILENCE_DURATION_MS=3000
PACKET_FILTER_MIN_SIZE=8
OPUS_FRAME_SIZE=960
AUDIO_SAMPLE_RATE=48000
AUDIO_CHANNELS=2
AVATAR_SIZE=64
# Webserver Configuration
WEBSERVER_PORT=3000
# Connection Configuration
VOICE_CONNECTION_TIMEOUT_MS=15000
RECONNECT_TIMEOUT_MS=5000
# Logging Configuration
LOG_LEVEL=info
NODE_ENV=development

2
.gitignore vendored
View File

@@ -1,4 +1,4 @@
node_modules node_modules
recordings recordings
.env .env
dist/

BIN
bun.lockb

Binary file not shown.

1532
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "bun --watch src/index.ts", "dev": "bun --watch src/index.ts",
"start": "bun src/index.ts", "start": "bun src/index.ts",
"build": "tsc --outDir dist",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"lint": "biome check --diagnostic-level=error .", "lint": "biome check --diagnostic-level=error .",
"format": "biome format --write .", "format": "biome format --write .",
@@ -14,25 +15,33 @@
"dependencies": { "dependencies": {
"@discordjs/opus": "^0.10.0", "@discordjs/opus": "^0.10.0",
"@discordjs/voice": "^0.19.1", "@discordjs/voice": "^0.19.1",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@snazzah/davey": "^0.1.10", "@snazzah/davey": "^0.1.10",
"crc-32": "^1.2.2", "better-sqlite3": "^12.10.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"discord.js-selfbot-v13": "^3.7.1", "discord.js-selfbot-v13": "^3.7.1",
"dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"ffmpeg-static": "^5.3.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"helmet": "^8.1.0",
"libsodium-wrappers": "^0.8.2", "libsodium-wrappers": "^0.8.2",
"node-crc": "^4.0.0", "p-retry": "^6.2.0",
"pino": "^9.4.0",
"pino-http": "^11.0.0",
"prism-media": "2.0.0-alpha.0", "prism-media": "2.0.0-alpha.0",
"prom-client": "^15.1.3",
"sodium-native": "^4.3.2", "sodium-native": "^4.3.2",
"ws": "^8.20.1" "ws": "^8.20.1",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "latest",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "latest", "@types/bun": "latest",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/fluent-ffmpeg": "^2.1.28", "@types/fluent-ffmpeg": "^2.1.28",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"pino-pretty": "^10.3.1",
"vitest": "latest" "vitest": "latest"
} }
} }

View File

@@ -1,37 +1,46 @@
// Configuration for the bot import { z } from "zod";
export interface AppConfig { import { ConfigError } from "./errors";
verbose: boolean;
recordingsDir: string;
recordingSegmentMs: number;
decoderRotateMs: number;
decoderCooldownMs: number;
}
export function parseBoolean( const configSchema = z.object({
value: string | undefined, DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
fallback: boolean, VOICE_CHANNEL_ID: z.string().min(1, "VOICE_CHANNEL_ID is required"),
): boolean { GUILD_ID: z.string().min(1, "GUILD_ID is required"),
if (value === "true") return true; VERBOSE: z
if (value === "false") return false; .string()
return fallback; .optional()
} .transform((v) => v === "true")
.default(false),
RECORDINGS_DIR: z.string().default("./recordings"),
RECORDING_SEGMENT_MS: z.coerce.number().positive().default(5000),
DECODER_ROTATE_MS: z.coerce.number().positive().default(5000),
DECODER_COOLDOWN_MS: z.coerce.number().positive().default(30000),
WEBSERVER_PORT: z.coerce.number().positive().default(3000),
VOICE_CONNECTION_TIMEOUT_MS: z.coerce.number().positive().default(15000),
RECONNECT_TIMEOUT_MS: z.coerce.number().positive().default(5000),
AUDIO_STREAM_SILENCE_DURATION_MS: z.coerce.number().positive().default(3000),
PACKET_FILTER_MIN_SIZE: z.coerce.number().positive().default(8),
OPUS_FRAME_SIZE: z.coerce.number().positive().default(960),
AUDIO_SAMPLE_RATE: z.coerce.number().positive().default(48000),
AUDIO_CHANNELS: z.coerce.number().positive().default(2),
AVATAR_SIZE: z.coerce.number().positive().default(64),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
NODE_ENV: z.enum(["development", "production"]).default("development"),
});
export function parsePositiveNumber( export type AppConfig = z.infer<typeof configSchema>;
value: string | undefined,
fallback: number,
): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
return { try {
verbose: parseBoolean(env.VERBOSE, false), return configSchema.parse(env);
recordingsDir: env.RECORDINGS_DIR ?? "./recordings", } catch (error) {
recordingSegmentMs: parsePositiveNumber(env.RECORDING_SEGMENT_MS, 5_000), if (error instanceof z.ZodError) {
decoderRotateMs: parsePositiveNumber(env.DECODER_ROTATE_MS, 5_000), const messages = error.issues
decoderCooldownMs: 30_000, .map((e) => `${e.path.join(".")}: ${e.message}`)
}; .join("\n");
throw new ConfigError(`Configuration validation failed:\n${messages}`);
}
throw error;
}
} }
export const config = loadConfig(); export const config = loadConfig();

42
src/errors.ts Normal file
View File

@@ -0,0 +1,42 @@
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 500,
) {
super(message);
this.name = "AppError";
Error.captureStackTrace(this, this.constructor);
}
}
export class ConfigError extends AppError {
constructor(message: string) {
super(message, "CONFIG_ERROR", 500);
this.name = "ConfigError";
}
}
export class AudioError extends AppError {
constructor(message: string) {
super(message, "AUDIO_ERROR", 500);
this.name = "AudioError";
}
}
export class VoiceConnectionError extends AppError {
constructor(message: string) {
super(message, "VOICE_CONNECTION_ERROR", 500);
this.name = "VoiceConnectionError";
}
}
export class ValidationError extends AppError {
constructor(
message: string,
public details?: Record<string, string[]>,
) {
super(message, "VALIDATION_ERROR", 400);
this.name = "ValidationError";
}
}

View File

@@ -1,34 +1,85 @@
import "./mock-crc"; import "./mock-crc";
import "libsodium-wrappers"; import "libsodium-wrappers";
import "@snazzah/davey"; import "@snazzah/davey";
import "dotenv/config";
import { getVoiceConnection } from "@discordjs/voice"; import { getVoiceConnection } from "@discordjs/voice";
import { Client } from "discord.js-selfbot-v13"; import { Client } from "discord.js-selfbot-v13";
import { config } from "./config"; import { config } from "./config";
import { createChildLogger } from "./logger";
import { discordPlayer } from "./player"; import { discordPlayer } from "./player";
import { startRecording } from "./recorder"; import { startRecording, stopRecording } from "./recorder";
import { startWebserver } from "./webserver"; import { startWebserver } from "./webserver";
// Validasi environment variables const logger = createChildLogger("bot");
const token = process.env.DISCORD_TOKEN;
const voiceChannelId = process.env.VOICE_CHANNEL_ID;
const guildId = process.env.GUILD_ID;
if (!token) throw new Error("Missing DISCORD_TOKEN in .env"); const token = config.DISCORD_TOKEN;
if (!voiceChannelId) throw new Error("Missing VOICE_CHANNEL_ID in .env"); const voiceChannelId = config.VOICE_CHANNEL_ID;
if (!guildId) throw new Error("Missing GUILD_ID in .env"); const guildId = config.GUILD_ID;
// Inisialisasi selfbot client (gunakan checkUpdate: false supaya tidak ada prompt update) // Inisialisasi selfbot client
const client = new Client(); const client = new Client();
// Track shutdown state
let isShuttingDown = false;
async function gracefulShutdown(signal: string) {
if (isShuttingDown) {
logger.warn(`Already shutting down, ignoring ${signal}`);
return;
}
isShuttingDown = true;
logger.info({ signal }, "Graceful shutdown initiated");
try {
// Step 1: Stop recording
if (guildId) {
logger.info("Stopping recording...");
stopRecording(guildId);
}
// Step 2: Pause player
logger.info("Pausing player...");
discordPlayer.pause();
// Step 3: Destroy voice connection
if (guildId) {
const connection = getVoiceConnection(guildId);
if (connection) {
logger.info("Destroying voice connection...");
try {
connection.destroy();
} catch (err) {
logger.warn({ error: err }, "Error destroying voice connection");
}
}
}
// Step 4: Destroy client
logger.info("Destroying Discord client...");
try {
client.destroy();
} catch (err) {
logger.warn({ error: err }, "Error destroying client");
}
logger.info("Graceful shutdown completed");
process.exit(0);
} catch (err) {
logger.error({ error: err }, "Error during graceful shutdown");
process.exit(1);
}
}
client.on("ready", async () => { client.on("ready", async () => {
if (config.verbose) { if (config.VERBOSE) {
console.log(`[bot] Logged in as ${client.user!.tag}`); logger.info({ user: client.user?.tag }, "Bot logged in");
} }
// Ambil guild // Ambil guild
const guild = client.guilds.cache.get(guildId!); const guild = client.guilds.cache.get(guildId!);
if (!guild) { if (!guild) {
console.error(`[bot] Guild not found: ${guildId}`); logger.error({ guildId }, "Guild not found");
process.exit(1); process.exit(1);
} }
@@ -38,49 +89,53 @@ client.on("ready", async () => {
(await guild.channels.fetch(voiceChannelId!).catch(() => null)); (await guild.channels.fetch(voiceChannelId!).catch(() => null));
if (!channel || channel.type !== "GUILD_VOICE") { if (!channel || channel.type !== "GUILD_VOICE") {
console.error( logger.error({ voiceChannelId }, "Voice channel not found or wrong type");
`[bot] Voice channel not found or wrong type: ${voiceChannelId}`,
);
process.exit(1); process.exit(1);
} }
if (config.verbose) { if (config.VERBOSE) {
console.log( logger.info(
`[bot] Joining voice channel: #${channel.name} (${channel.id})`, { channelName: channel.name, channelId: channel.id },
"Joining voice channel",
); );
} }
await startRecording(client, channel as any); await startRecording(client, channel as any);
// Set up player connection // Set up player connection
const connection = getVoiceConnection(guildId!); const connection = getVoiceConnection(guildId!);
if (connection) { if (connection) {
discordPlayer.setConnection(connection); discordPlayer.setConnection(connection);
console.log("[bot] Player connected to voice channel"); logger.info("Player connected to voice channel");
} }
// Start Webserver // Start Webserver
startWebserver(3000); startWebserver(config.WEBSERVER_PORT);
}); });
client.on("error", (err) => { client.on("error", (err) => {
console.error("[bot] Client error:", err); logger.error({ error: err }, "Client error");
}); });
// Graceful shutdown // Graceful shutdown handlers
process.on("SIGINT", () => { process.on("SIGINT", () => {
if (config.verbose) { gracefulShutdown("SIGINT");
console.log("\n[bot] Shutting down...");
}
client.destroy();
process.exit(0);
}); });
process.on("SIGTERM", () => { process.on("SIGTERM", () => {
if (config.verbose) { gracefulShutdown("SIGTERM");
console.log("[bot] Terminating..."); });
}
client.destroy(); // Handle uncaught exceptions
process.exit(0); process.on("uncaughtException", (err) => {
logger.error({ error: err }, "Uncaught exception");
gracefulShutdown("uncaughtException");
});
// Handle unhandled promise rejections
process.on("unhandledRejection", (reason, promise) => {
logger.error({ reason, promise }, "Unhandled rejection");
gracefulShutdown("unhandledRejection");
}); });
client.login(token); client.login(token);

22
src/logger.ts Normal file
View File

@@ -0,0 +1,22 @@
import pino from "pino";
const isDev = process.env.NODE_ENV !== "production";
export const logger = pino({
level: process.env.LOG_LEVEL || (isDev ? "debug" : "info"),
transport: isDev
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "SYS:standard",
ignore: "pid,hostname",
},
}
: undefined,
timestamp: pino.stdTimeFunctions.isoTime,
});
export const createChildLogger = (context: string) => {
return logger.child({ context });
};

78
src/metrics.ts Normal file
View File

@@ -0,0 +1,78 @@
import { Counter, Gauge, Histogram, register } from "prom-client";
// Audio metrics
export const audioLevelGauge = new Gauge({
name: "audio_level_db",
help: "Current audio level in dB",
labelNames: ["user_id"],
});
export const recordingDurationCounter = new Counter({
name: "recording_duration_seconds_total",
help: "Total recording duration in seconds",
labelNames: ["user_id"],
});
export const activeRecordingsGauge = new Gauge({
name: "active_recordings",
help: "Number of active recordings",
});
export const recordedSegmentsCounter = new Counter({
name: "recorded_segments_total",
help: "Total number of recorded segments",
labelNames: ["user_id"],
});
// Connection metrics
export const voiceConnectionsGauge = new Gauge({
name: "voice_connections_active",
help: "Number of active voice connections",
});
export const connectionErrorsCounter = new Counter({
name: "connection_errors_total",
help: "Total number of connection errors",
labelNames: ["error_type"],
});
export const reconnectAttemptsCounter = new Counter({
name: "reconnect_attempts_total",
help: "Total number of reconnection attempts",
});
// WebSocket metrics
export const wsClientsGauge = new Gauge({
name: "websocket_clients_connected",
help: "Number of connected WebSocket clients",
});
export const wsMessagesCounter = new Counter({
name: "websocket_messages_total",
help: "Total WebSocket messages sent",
labelNames: ["message_type"],
});
// HTTP metrics
export const httpRequestDurationHistogram = new Histogram({
name: "http_request_duration_seconds",
help: "HTTP request duration in seconds",
labelNames: ["method", "route", "status"],
buckets: [0.001, 0.01, 0.1, 0.5, 1, 2, 5],
});
export const httpRequestsCounter = new Counter({
name: "http_requests_total",
help: "Total HTTP requests",
labelNames: ["method", "route", "status"],
});
// System metrics
export const uptimeGauge = new Gauge({
name: "process_uptime_seconds",
help: "Process uptime in seconds",
});
export async function getMetrics(): Promise<string> {
return register.metrics();
}

36
src/middleware.ts Normal file
View File

@@ -0,0 +1,36 @@
import { NextFunction, Request, Response } from "express";
import { AppError } from "./errors";
import { createChildLogger } from "./logger";
const logger = createChildLogger("middleware");
export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction,
) {
if (err instanceof AppError) {
logger.error(
{ code: err.code, statusCode: err.statusCode, message: err.message },
"Application error",
);
return res.status(err.statusCode).json({
error: err.code,
message: err.message,
});
}
logger.error({ error: err.message, stack: err.stack }, "Unexpected error");
res.status(500).json({
error: "INTERNAL_SERVER_ERROR",
message: "An unexpected error occurred",
});
}
export function notFoundHandler(_req: Request, res: Response) {
res.status(404).json({
error: "NOT_FOUND",
message: "Endpoint not found",
});
}

230
src/muxer-queue.ts Normal file
View File

@@ -0,0 +1,230 @@
import path from "node:path";
import Database from "better-sqlite3";
import { createChildLogger } from "./logger";
const logger = createChildLogger("muxer-queue");
export interface MuxerJobData {
userId: string;
sessionId: string;
recordingsDir: string;
outputDir: string;
}
interface StoredJob {
id: string;
data: string;
status: "pending" | "processing" | "completed" | "failed";
attempts: number;
maxAttempts: number;
createdAt: number;
updatedAt: number;
error?: string;
}
const dbPath = path.join(process.cwd(), ".muxer-queue.db");
let db: Database.Database | null = null;
function initializeDatabase(): Database.Database {
const database = new Database(dbPath);
database.pragma("journal_mode = WAL");
database.exec(`
CREATE TABLE IF NOT EXISTS muxer_jobs (
id TEXT PRIMARY KEY,
data TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INTEGER NOT NULL DEFAULT 0,
maxAttempts INTEGER NOT NULL DEFAULT 3,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
error TEXT
);
CREATE INDEX IF NOT EXISTS idx_status ON muxer_jobs(status);
CREATE INDEX IF NOT EXISTS idx_createdAt ON muxer_jobs(createdAt);
`);
return database;
}
function getDatabase(): Database.Database {
if (!db) {
db = initializeDatabase();
}
return db;
}
export async function enqueueMuxerJob(data: MuxerJobData): Promise<string> {
try {
const database = getDatabase();
const jobId = `${data.userId}-${data.sessionId}`;
const now = Date.now();
const stmt = database.prepare(`
INSERT INTO muxer_jobs (id, data, status, attempts, maxAttempts, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(jobId, JSON.stringify(data), "pending", 0, 3, now, now);
logger.info(
{ jobId, userId: data.userId, sessionId: data.sessionId },
"Muxer job enqueued",
);
return jobId;
} catch (error) {
logger.error(
{
userId: data.userId,
error: error instanceof Error ? error.message : String(error),
},
"Failed to enqueue muxer job",
);
throw error;
}
}
export async function getPendingJobs(): Promise<StoredJob[]> {
const database = getDatabase();
const stmt = database.prepare(`
SELECT id, data, status, attempts, maxAttempts, createdAt, updatedAt, error
FROM muxer_jobs
WHERE status = 'pending'
ORDER BY createdAt ASC
LIMIT 10
`);
const rows = stmt.all() as Array<{
id: string;
data: string;
status: string;
attempts: number;
maxAttempts: number;
createdAt: number;
updatedAt: number;
error?: string;
}>;
return rows.map((row) => ({
...row,
status: row.status as "pending" | "processing" | "completed" | "failed",
}));
}
export async function updateJobStatus(
jobId: string,
status: "processing" | "completed" | "failed",
error?: string,
): Promise<void> {
const database = getDatabase();
const now = Date.now();
if (status === "failed") {
const stmt = database.prepare(`
UPDATE muxer_jobs
SET status = ?, attempts = attempts + 1, updatedAt = ?, error = ?
WHERE id = ?
`);
stmt.run(status, now, error || null, jobId);
} else {
const stmt = database.prepare(`
UPDATE muxer_jobs
SET status = ?, updatedAt = ?
WHERE id = ?
`);
stmt.run(status, now, jobId);
}
logger.info({ jobId, status, error }, "Job status updated");
}
export async function retryFailedJob(jobId: string): Promise<boolean> {
const database = getDatabase();
const job = database
.prepare("SELECT * FROM muxer_jobs WHERE id = ?")
.get(jobId) as StoredJob | undefined;
if (!job) {
logger.warn({ jobId }, "Job not found");
return false;
}
if (job.attempts >= job.maxAttempts) {
logger.warn(
{ jobId, attempts: job.attempts, maxAttempts: job.maxAttempts },
"Max retry attempts reached",
);
return false;
}
const stmt = database.prepare(`
UPDATE muxer_jobs
SET status = 'pending', updatedAt = ?
WHERE id = ?
`);
stmt.run(Date.now(), jobId);
logger.info({ jobId, attempt: job.attempts + 1 }, "Job retried");
return true;
}
export async function cleanupCompletedJobs(
olderThanMs: number = 24 * 60 * 60 * 1000,
): Promise<number> {
const database = getDatabase();
const cutoffTime = Date.now() - olderThanMs;
const stmt = database.prepare(`
DELETE FROM muxer_jobs
WHERE status = 'completed' AND updatedAt < ?
`);
const result = stmt.run(cutoffTime);
logger.info({ deletedCount: result.changes }, "Cleaned up completed jobs");
return result.changes;
}
export async function getJobStats(): Promise<{
pending: number;
processing: number;
completed: number;
failed: number;
}> {
const database = getDatabase();
const stats = database
.prepare(`
SELECT
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'processing' THEN 1 ELSE 0 END) as processing,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed
FROM muxer_jobs
`)
.get() as {
pending: number | null;
processing: number | null;
completed: number | null;
failed: number | null;
};
return {
pending: stats.pending || 0,
processing: stats.processing || 0,
completed: stats.completed || 0,
failed: stats.failed || 0,
};
}
export async function closeQueue(): Promise<void> {
if (db) {
db.close();
db = null;
logger.info("Muxer queue closed");
}
}

View File

@@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { import {
EndBehaviorType, EndBehaviorType,
entersState, entersState,
@@ -6,14 +8,22 @@ import {
VoiceConnectionStatus, VoiceConnectionStatus,
} from "@discordjs/voice"; } from "@discordjs/voice";
import type { Client, VoiceChannel } from "discord.js-selfbot-v13"; import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
import fs from "fs";
import path from "path";
import prism from "prism-media";
import { pipeline } from "stream/promises";
import { config } from "./config"; import { config } from "./config";
import { createChildLogger } from "./logger";
import { PacketFilter } from "./packetFilter"; import { PacketFilter } from "./packetFilter";
import { subscribeToAudioStream } from "./recorder/audioStream";
import { OpusDecoder } from "./recorder/decoder";
import {
collectUserMetadata,
createSegmentMetadata,
} from "./recorder/metadata";
import { SegmentManager } from "./recorder/segment";
import { retryWithBackoff } from "./retry";
import type { PcmBroadcaster } from "./types";
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings"; const logger = createChildLogger("recorder");
const recordingsDir = config.RECORDINGS_DIR;
// Pastikan folder recordings ada // Pastikan folder recordings ada
if (!fs.existsSync(recordingsDir)) { if (!fs.existsSync(recordingsDir)) {
@@ -36,71 +46,58 @@ export async function startRecording(
debug: true, debug: true,
}); });
if (config.verbose) { logger.info({ channelName: channel.name }, "Joining voice channel");
console.log(`[recorder] Joining voice channel: #${channel.name}`);
}
connection.on("debug", (msg) => { connection.on("debug", (msg) => {
if (config.verbose) { if (config.VERBOSE) {
console.log(`[voice-debug] ${msg}`); logger.debug({ message: msg }, "Voice debug");
} }
}); });
connection.on("error", (err) => { connection.on("error", (err) => {
console.error(`[voice-error]`, err); logger.error({ error: err }, "Voice connection error");
}); });
// Tunggu sampai benar-benar terhubung // Tunggu sampai benar-benar terhubung dengan retry logic
try { try {
await entersState(connection, VoiceConnectionStatus.Ready, 15_000); await retryWithBackoff(
if (config.verbose) { () =>
console.log("[recorder] Connected to voice channel. Recording started."); entersState(
} connection,
VoiceConnectionStatus.Ready,
config.VOICE_CONNECTION_TIMEOUT_MS,
),
{
retries: 3,
minTimeout: 1000,
maxTimeout: 5000,
logger,
},
);
logger.info("Connected to voice channel. Recording started");
} catch (err) { } catch (err) {
console.error("[recorder] Failed to connect:", err); logger.error({ error: err }, "Failed to connect to voice channel");
connection.destroy(); connection.destroy();
return; return;
} }
const receiver = connection.receiver; const receiver = connection.receiver;
const broadcaster = globalThis as typeof globalThis & PcmBroadcaster;
// Dengarkan siapapun yang mulai bicara // Dengarkan siapapun yang mulai bicara
receiver.speaking.on("start", async (userId) => { receiver.speaking.on("start", async (userId) => {
// Coba ambil data user dari cache atau fetch dari API const userMetadata = await collectUserMetadata(client, userId, channel);
const user = logger.info(
client.users.cache.get(userId) || { userId, username: userMetadata.username },
(await client.users.fetch(userId).catch(() => null)); "Voice activity detected",
const member = );
channel.guild.members.cache.get(userId) ||
(await channel.guild.members.fetch(userId).catch(() => null));
const username = user?.username ?? "Unknown User";
const avatarUrl =
user?.displayAvatarURL({ format: "png", size: 64 }) ??
"https://cdn.discordapp.com/embed/avatars/0.png";
const displayName = member?.displayName ?? username;
const roles =
member?.roles.cache
.filter((role) => role.id !== channel.guild.id)
.sort((a, b) => b.position - a.position)
.map((role) => ({
id: role.id,
name: role.name,
position: role.position,
})) ?? [];
const highestRole = roles.length > 0 ? roles[0] : null;
const joinedTimestamp = member?.joinedTimestamp ?? null;
// Tampilkan format "nama user [voice activity]"
console.log(`${username} [voice activity]`);
// Notify webserver // Notify webserver
if ((global as any).updateActiveUser) { broadcaster.updateActiveUser?.(userId, {
(global as any).updateActiveUser(userId, { username: userMetadata.username,
username, avatar: userMetadata.avatarUrl,
avatar: avatarUrl,
speaking: true, speaking: true,
}); });
}
// Jangan record kalau sudah ada stream aktif untuk user ini // Jangan record kalau sudah ada stream aktif untuk user ini
if (receiver.subscriptions.has(userId)) return; if (receiver.subscriptions.has(userId)) return;
@@ -108,270 +105,136 @@ export async function startRecording(
const timestamp = Date.now(); const timestamp = Date.now();
const sessionStartTime = timestamp; const sessionStartTime = timestamp;
const sessionId = `${userId}-${sessionStartTime}`; const sessionId = `${userId}-${sessionStartTime}`;
const recordingSegmentMsRaw = Number(
process.env.RECORDING_SEGMENT_MS ?? 5_000,
);
const recordingSegmentMs =
Number.isFinite(recordingSegmentMsRaw) && recordingSegmentMsRaw > 0
? recordingSegmentMsRaw
: 0;
const userDir = path.join(recordingsDir, userId); const userDir = path.join(recordingsDir, userId);
if (!fs.existsSync(userDir)) { if (!fs.existsSync(userDir)) {
fs.mkdirSync(userDir, { recursive: true }); fs.mkdirSync(userDir, { recursive: true });
} }
try {
// --- OGG file recording with segment rotation ---
const packetFilterForOgg = new PacketFilter(
config.PACKET_FILTER_MIN_SIZE,
);
const audioStream = receiver.subscribe(userId, { const audioStream = receiver.subscribe(userId, {
end: { end: {
behavior: EndBehaviorType.AfterSilence, behavior: EndBehaviorType.AfterSilence,
duration: 3000, duration: 3000,
}, },
}); });
try {
// --- OGG file recording with segment rotation ---
const packetFilterForOgg = new PacketFilter(8);
const oggPacketStream = audioStream.pipe(packetFilterForOgg); const oggPacketStream = audioStream.pipe(packetFilterForOgg);
let segmentIndex = 0; const segmentManager = new SegmentManager(
let currentSegment: { userDir,
index: number; config.RECORDING_SEGMENT_MS,
startTime: number;
endTime: number | null;
filename: string;
jsonFilename: string;
oggStream: any;
out: fs.WriteStream;
} | null = null;
const openSegment = () => {
const index = segmentIndex++;
const startTime = Date.now();
const segmentFilename = path.join(userDir, `${startTime}.ogg`);
const segmentJsonFilename = path.join(userDir, `${startTime}.json`);
const oggStream = new prism.opus.OggLogicalBitstream({
opusHead: new prism.opus.OpusHead({
channelCount: 2,
sampleRate: 48000,
}),
pageSizeControl: { maxPackets: 10 },
crc: true,
});
const out = fs.createWriteStream(segmentFilename);
oggPacketStream.pipe(oggStream).pipe(out);
const segment = {
index,
startTime,
endTime: null as number | null,
filename: segmentFilename,
jsonFilename: segmentJsonFilename,
oggStream,
out,
};
out.on("finish", () => {
if (config.verbose) {
console.log(`[recorder] Saved: ${segment.filename}`);
}
const endTime = segment.endTime ?? Date.now();
const eventMetadata = {
userId,
username,
tag: user?.tag ?? "Unknown#0000",
displayName,
avatarUrl,
bot: user?.bot ?? false,
roles,
highestRole,
joinedTimestamp,
sessionId,
sessionStartTime,
segmentIndex: segment.index,
segmentMs: recordingSegmentMs,
startTime: segment.startTime,
endTime,
durationMs: endTime - segment.startTime,
filename: path.basename(segment.filename),
};
fs.writeFileSync(
segment.jsonFilename,
JSON.stringify(eventMetadata, null, 2),
); );
if (config.verbose) {
console.log(`[recorder] Saved metadata: ${segment.jsonFilename}`);
}
});
out.on("error", (err) => {
console.error(`[recorder] File write error ${userId}:`, err.message);
});
return segment;
};
const closeSegment = () => {
if (!currentSegment) return;
currentSegment.endTime = Date.now();
oggPacketStream.unpipe(currentSegment.oggStream);
currentSegment.oggStream.end();
currentSegment = null;
};
const rotateSegmentIfNeeded = () => {
if (!currentSegment) return;
if (recordingSegmentMs <= 0) return;
if (Date.now() - currentSegment.startTime < recordingSegmentMs) return;
closeSegment();
currentSegment = openSegment();
};
currentSegment = openSegment();
// --- Web broadcast: prism decoder with safe restart and cooldown --- // --- Web broadcast: prism decoder with safe restart and cooldown ---
// OpusScript can crash on long/invalid streams; avoid taking down the process. const decoder = new OpusDecoder({
const decoderConfig = { cooldownMs: config.DECODER_COOLDOWN_MS,
frameSize: 960, rotateMs: config.DECODER_ROTATE_MS,
channels: 2 as const, onData: (pcm) => {
rate: 48000 as const, if (!broadcaster.broadcastPcmToWeb) return;
};
const decoderCooldownMs = 30_000;
const decoderRotateMs = Number(process.env.DECODER_ROTATE_MS ?? 5_000);
let currentDecoder: prism.opus.Decoder | null = null;
let decoderDisabledUntil = 0;
let decoderCreatedAt = 0;
const handlePcm = (pcm: Buffer) => {
if (!(global as any).broadcastPcmToWeb) return;
// Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd sample) // Downsample 48kHz stereo → 24kHz mono (left channel, every 2nd sample)
const outBuf = Buffer.alloc(pcm.length / 4); const outBuf = Buffer.alloc(pcm.length / 4);
for (let i = 0; i < outBuf.length / 2; i++) { for (let i = 0; i < outBuf.length / 2; i++) {
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2); outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
} }
(global as any).broadcastPcmToWeb(outBuf, userId); broadcaster.broadcastPcmToWeb(outBuf, userId);
}; },
const destroyDecoder = () => {
if (!currentDecoder) return;
currentDecoder.removeAllListeners();
currentDecoder.destroy();
currentDecoder = null;
decoderCreatedAt = 0;
};
const createDecoder = () => {
if (Date.now() < decoderDisabledUntil) return null;
try {
const d = new prism.opus.Decoder(decoderConfig);
d.on("data", handlePcm);
d.on("error", (err) => {
console.warn("[recorder] Opus decoder error, cooling down:", err);
decoderDisabledUntil = Date.now() + decoderCooldownMs;
destroyDecoder();
}); });
decoderCreatedAt = Date.now();
return d; let currentSegment = segmentManager.open(oggPacketStream);
} catch (err) { currentSegment.out.on("finish", () => {
console.warn( if (config.VERBOSE) {
"[recorder] Opus decoder init failed, cooling down:", logger.info({ filename: currentSegment.filename }, "Segment saved");
err, }
const metadata = createSegmentMetadata(
userMetadata,
currentSegment,
sessionId,
sessionStartTime,
config.RECORDING_SEGMENT_MS,
);
fs.writeFileSync(
currentSegment.jsonFilename,
JSON.stringify(metadata, null, 2),
);
if (config.VERBOSE) {
logger.info(
{ jsonFile: currentSegment.jsonFilename },
"Metadata saved",
); );
decoderDisabledUntil = Date.now() + decoderCooldownMs;
return null;
} }
}; });
const rotateDecoderIfNeeded = () => { currentSegment.out.on("error", (err) => {
if (!currentDecoder || decoderRotateMs <= 0) return; logger.error({ userId, error: err.message }, "File write error");
if (Date.now() - decoderCreatedAt < decoderRotateMs) return; });
destroyDecoder();
currentDecoder = createDecoder();
};
const ensureDecoder = () => {
if (!currentDecoder) {
currentDecoder = createDecoder();
}
return currentDecoder;
};
// Feed Opus packets one-by-one // Feed Opus packets one-by-one
let packetCount = 0; subscribeToAudioStream(receiver, userId, {
audioStream.on("data", (chunk: Buffer) => { onPacket: (chunk) => {
packetCount++; if (chunk.length < 8) return;
if (packetCount <= 5) { segmentManager.rotateIfNeeded(oggPacketStream);
console.log( if (!broadcaster.broadcastPcmToWeb) return;
`[recorder] Pkt #${packetCount} from ${userId}: ${chunk.length}b | 0x${chunk.slice(0, 4).toString("hex")}`, decoder.rotateIfNeeded();
);
}
if (chunk.length < 8) return; // skip tiny control/DTX packets
rotateSegmentIfNeeded();
if (!(global as any).broadcastPcmToWeb) return;
rotateDecoderIfNeeded();
const decoder = ensureDecoder();
if (!decoder) return;
try {
decoder.write(chunk); decoder.write(chunk);
} catch (err) { },
console.warn( onEnd: () => {
"[recorder] Opus decoder write failed, cooling down:", segmentManager.close(oggPacketStream);
err, decoder.destroy();
); broadcaster.updateActiveUser?.(userId, {
decoderDisabledUntil = Date.now() + decoderCooldownMs; username: userMetadata.username,
destroyDecoder(); avatar: userMetadata.avatarUrl,
}
});
audioStream.on("end", () => {
closeSegment();
destroyDecoder();
if ((global as any).updateActiveUser) {
(global as any).updateActiveUser(userId, {
username,
avatar: avatarUrl,
speaking: false, speaking: false,
}); });
} },
onError: (error) => {
segmentManager.close(oggPacketStream);
decoder.destroy();
logger.error({ userId, error: error.message }, "Audio stream error");
},
}); });
audioStream.on("error", (err) => {
closeSegment();
destroyDecoder();
console.error(`[recorder] Audio Stream error ${userId}:`, err.message);
});
packetFilterForOgg.on("error", (err) => { packetFilterForOgg.on("error", (err) => {
closeSegment(); segmentManager.close(oggPacketStream);
console.error( logger.error({ userId, error: err.message }, "PacketFilter error");
`[recorder] PacketFilter(ogg) error ${userId}:`,
err.message,
);
}); });
} catch (e) { } catch (e) {
console.error(`[recorder] Failed to create stream for ${userId}:`, e); logger.error(
{ userId, error: e instanceof Error ? e.message : String(e) },
"Failed to create stream",
);
} }
}); });
// Handle disconnect yang tidak disengaja // Handle disconnect yang tidak disengaja
connection.on(VoiceConnectionStatus.Disconnected, async () => { connection.on(VoiceConnectionStatus.Disconnected, async () => {
if (config.verbose) { if (config.VERBOSE) {
console.warn( logger.warn("Disconnected from voice channel. Reconnecting...");
"[recorder] Disconnected from voice channel. Reconnecting...",
);
} }
try { try {
await Promise.race([ await Promise.race([
entersState(connection, VoiceConnectionStatus.Signalling, 5_000), entersState(
entersState(connection, VoiceConnectionStatus.Connecting, 5_000), connection,
VoiceConnectionStatus.Signalling,
config.RECONNECT_TIMEOUT_MS,
),
entersState(
connection,
VoiceConnectionStatus.Connecting,
config.RECONNECT_TIMEOUT_MS,
),
]); ]);
// Berhasil reconnect // Berhasil reconnect
} catch { } catch {
console.error("[recorder] Could not reconnect. Destroying connection."); logger.error("Could not reconnect. Destroying connection");
connection.destroy(); connection.destroy();
} }
}); });
connection.on(VoiceConnectionStatus.Destroyed, () => { connection.on(VoiceConnectionStatus.Destroyed, () => {
if (config.verbose) { if (config.VERBOSE) {
console.log("[recorder] Voice connection destroyed."); logger.info("Voice connection destroyed");
} }
}); });
} }
@@ -383,10 +246,10 @@ export function stopRecording(guildId: string): void {
const connection = getVoiceConnection(guildId); const connection = getVoiceConnection(guildId);
if (connection) { if (connection) {
connection.destroy(); connection.destroy();
if (config.verbose) { if (config.VERBOSE) {
console.log("[recorder] Recording stopped and disconnected."); logger.info("Recording stopped and disconnected");
} }
} else { } else {
console.warn("[recorder] No active connection to stop."); logger.warn("No active connection to stop");
} }
} }

View File

@@ -1,4 +1,5 @@
import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice"; import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice";
import { config } from "../config";
export interface AudioStreamHandlers { export interface AudioStreamHandlers {
onPacket: (chunk: Buffer) => void; onPacket: (chunk: Buffer) => void;
@@ -14,7 +15,7 @@ export function subscribeToAudioStream(
const audioStream = receiver.subscribe(userId, { const audioStream = receiver.subscribe(userId, {
end: { end: {
behavior: EndBehaviorType.AfterSilence, behavior: EndBehaviorType.AfterSilence,
duration: 3000, duration: config.AUDIO_STREAM_SILENCE_DURATION_MS,
}, },
}); });

View File

@@ -1,4 +1,5 @@
import prism from "prism-media"; import prism from "prism-media";
import { config } from "../config";
export interface OpusDecoderOptions { export interface OpusDecoderOptions {
cooldownMs: number; cooldownMs: number;
@@ -22,7 +23,17 @@ export class OpusDecoder {
this.onData = options.onData; this.onData = options.onData;
this.createDecoderFn = this.createDecoderFn =
options.createDecoder ?? options.createDecoder ??
(() => new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48000 })); (() =>
new prism.opus.Decoder({
frameSize: config.OPUS_FRAME_SIZE,
channels: config.AUDIO_CHANNELS as 1 | 2,
rate: config.AUDIO_SAMPLE_RATE as
| 8000
| 12000
| 16000
| 24000
| 48000,
}));
} }
rotateIfNeeded(): void { rotateIfNeeded(): void {
@@ -38,7 +49,10 @@ export class OpusDecoder {
try { try {
decoder.write(chunk); decoder.write(chunk);
} catch (error) { } catch (error) {
console.warn("[recorder] Opus decoder write failed, cooling down:", error); console.warn(
"[recorder] Opus decoder write failed, cooling down:",
error,
);
this.coolDown(); this.coolDown();
} }
} }

View File

@@ -1,5 +1,6 @@
import path from "node:path"; import path from "node:path";
import type { Client, VoiceChannel } from "discord.js-selfbot-v13"; import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
import { config } from "../config";
import type { SegmentMetadata, SegmentState, UserMetadata } from "../types"; import type { SegmentMetadata, SegmentState, UserMetadata } from "../types";
export async function collectUserMetadata( export async function collectUserMetadata(
@@ -18,8 +19,11 @@ export async function collectUserMetadata(
member?.roles.cache member?.roles.cache
.filter((role) => role.id !== channel.guild.id) .filter((role) => role.id !== channel.guild.id)
.sort((a, b) => b.position - a.position) .sort((a, b) => b.position - a.position)
.map((role) => ({ id: role.id, name: role.name, position: role.position })) ?? .map((role) => ({
[]; id: role.id,
name: role.name,
position: role.position,
})) ?? [];
return { return {
userId, userId,
@@ -27,8 +31,19 @@ export async function collectUserMetadata(
tag: user?.tag ?? "Unknown#0000", tag: user?.tag ?? "Unknown#0000",
displayName: member?.displayName ?? username, displayName: member?.displayName ?? username,
avatarUrl: avatarUrl:
user?.displayAvatarURL({ format: "png", size: 64 }) ?? user?.displayAvatarURL({
"https://cdn.discordapp.com/embed/avatars/0.png", format: "png",
size: config.AVATAR_SIZE as
| 16
| 32
| 64
| 128
| 256
| 512
| 1024
| 2048
| 4096,
}) ?? "https://cdn.discordapp.com/embed/avatars/0.png",
bot: user?.bot ?? false, bot: user?.bot ?? false,
roles, roles,
highestRole: roles[0] ?? null, highestRole: roles[0] ?? null,

View File

@@ -33,7 +33,10 @@ export class SegmentManager {
open(oggPacketStream: NodeJS.ReadableStream): SegmentState { open(oggPacketStream: NodeJS.ReadableStream): SegmentState {
const index = this.segmentIndex++; const index = this.segmentIndex++;
const startTime = Date.now(); const startTime = Date.now();
const { filename, jsonFilename } = buildSegmentPaths(this.userDir, startTime); const { filename, jsonFilename } = buildSegmentPaths(
this.userDir,
startTime,
);
const oggStream = new prism.opus.OggLogicalBitstream({ const oggStream = new prism.opus.OggLogicalBitstream({
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }), opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48000 }),
pageSizeControl: { maxPackets: 10 }, pageSizeControl: { maxPackets: 10 },

42
src/retry.ts Normal file
View File

@@ -0,0 +1,42 @@
import pRetry from "p-retry";
import type { Logger } from "pino";
export interface RetryOptions {
retries?: number;
minTimeout?: number;
maxTimeout?: number;
factor?: number;
logger?: Logger;
}
export async function retryWithBackoff<T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
retries = 3,
minTimeout = 1000,
maxTimeout = 30000,
factor = 2,
logger,
} = options;
return pRetry(fn, {
retries,
minTimeout,
maxTimeout,
factor,
onFailedAttempt: (error) => {
if (logger) {
logger.warn(
{
attempt: error.attemptNumber,
retriesLeft: error.retriesLeft,
error: error.message,
},
"Retry attempt",
);
}
},
});
}

38
src/validation.ts Normal file
View File

@@ -0,0 +1,38 @@
import { plainToClass } from "class-transformer";
import { IsBoolean, IsString, validate } from "class-validator";
export class UserStateUpdate {
@IsString()
userId!: string;
@IsString()
username!: string;
@IsString()
avatar!: string;
@IsBoolean()
speaking!: boolean;
}
export class AudioMessage {
data!: Buffer;
userId!: string;
}
export async function validateUserStateUpdate(
data: unknown,
): Promise<UserStateUpdate | null> {
if (typeof data !== "object" || data === null) {
return null;
}
const obj = plainToClass(UserStateUpdate, data);
const errors = await validate(obj);
if (errors.length > 0) {
return null;
}
return obj;
}

View File

@@ -1,10 +1,16 @@
import express from "express"; import express from "express";
import helmet from "helmet";
import http from "http"; import http from "http";
import path from "path"; import path from "path";
import pinoHttp from "pino-http";
import prism from "prism-media"; import prism from "prism-media";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { createChildLogger, logger } from "./logger";
import { getMetrics, uptimeGauge } from "./metrics";
import { discordPlayer } from "./player"; import { discordPlayer } from "./player";
const wsLogger = createChildLogger("webserver");
const activeUsers = new Map< const activeUsers = new Map<
string, string,
{ username: string; avatar: string; speaking: boolean } { username: string; avatar: string; speaking: boolean }
@@ -42,12 +48,34 @@ export function startWebserver(port: number = 3000) {
const wsPort = port + 1; const wsPort = port + 1;
const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" }); const wss = new WebSocketServer({ port: wsPort, host: "0.0.0.0" });
console.log( wsLogger.info({ wsPort }, "WebSocket server listening");
`[webserver] WebSocket server listening on ws://0.0.0.0:${wsPort}`,
); // Security headers
app.use(helmet());
// HTTP request logging
app.use(pinoHttp({ logger }));
app.use(express.static(path.join(__dirname, "../public"))); app.use(express.static(path.join(__dirname, "../public")));
// Health check endpoint
app.get("/health", (_req, res) => {
res.json({
status: "ok",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
activeUsers: activeUsers.size,
wsClients: wsClients.size,
});
});
// Metrics endpoint
app.get("/metrics", async (_req, res) => {
res.set("Content-Type", "text/plain");
uptimeGauge.set(process.uptime());
res.send(await getMetrics());
});
// Inbound: Discord PCM → tagged chunks → browser // Inbound: Discord PCM → tagged chunks → browser
(global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => { (global as any).broadcastPcmToWeb = (chunk: Buffer, userId: string) => {
let hash = 0; let hash = 0;
@@ -124,9 +152,7 @@ export function startWebserver(port: number = 3000) {
setInterval(() => { setInterval(() => {
if (dbCount > 0) { if (dbCount > 0) {
const avg = dbAccum / dbCount; const avg = dbAccum / dbCount;
console.log( wsLogger.info({ level: avg.toFixed(1), frames: dbCount }, "Audio level");
`[transmit] Audio level: ${avg.toFixed(1)} dBFS (${dbCount} frames/2s)`,
);
dbAccum = 0; dbAccum = 0;
dbCount = 0; dbCount = 0;
} }
@@ -140,8 +166,8 @@ export function startWebserver(port: number = 3000) {
if (pcmBuffer.length >= BYTES_PER_FRAME) { if (pcmBuffer.length >= BYTES_PER_FRAME) {
// Real audio available // Real audio available
frame = pcmBuffer.slice(0, BYTES_PER_FRAME); frame = pcmBuffer.subarray(0, BYTES_PER_FRAME);
pcmBuffer = pcmBuffer.slice(BYTES_PER_FRAME); pcmBuffer = pcmBuffer.subarray(BYTES_PER_FRAME);
// Track level for logging // Track level for logging
dbAccum += rmsDb(frame); dbAccum += rmsDb(frame);
@@ -150,7 +176,7 @@ export function startWebserver(port: number = 3000) {
if (playerPaused) { if (playerPaused) {
discordPlayer.unpause(); discordPlayer.unpause();
playerPaused = false; playerPaused = false;
console.log("[transmit] Transmitting — Discord indicator ON"); wsLogger.info("Transmitting — Discord indicator ON");
} }
} else if (msSinceAudio < SILENCE_TAIL_MS && msSinceAudio > 0) { } else if (msSinceAudio < SILENCE_TAIL_MS && msSinceAudio > 0) {
// Buffer drained but audio was recent — pad silence to avoid OGG gap // Buffer drained but audio was recent — pad silence to avoid OGG gap
@@ -159,7 +185,7 @@ export function startWebserver(port: number = 3000) {
// No audio for a while — pause Discord indicator // No audio for a while — pause Discord indicator
discordPlayer.pause(); discordPlayer.pause();
playerPaused = true; playerPaused = true;
console.log("[transmit] Stopped — Discord indicator OFF"); wsLogger.info("Stopped — Discord indicator OFF");
return; return;
} else { } else {
return; // already paused, nothing to do return; // already paused, nothing to do
@@ -173,7 +199,7 @@ export function startWebserver(port: number = 3000) {
}, 20); }, 20);
wss.on("connection", (ws) => { wss.on("connection", (ws) => {
console.log("[webserver] New WebSocket connection on port " + wsPort); wsLogger.info({ wsPort }, "New WebSocket connection");
wsClients.add(ws); wsClients.add(ws);
ws.send( ws.send(
@@ -208,8 +234,6 @@ export function startWebserver(port: number = 3000) {
}); });
server.listen(port, "0.0.0.0", () => { server.listen(port, "0.0.0.0", () => {
console.log( wsLogger.info({ port }, "Web interface listening");
`[webserver] Web interface listening on http://0.0.0.0:${port}`,
);
}); });
} }

View File

@@ -7,7 +7,9 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"outDir": "dist", "outDir": "dist",
"rootDir": "src" "rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]