chore: add code quality tooling
This commit is contained in:
18
biome.json
18
biome.json
@@ -1,15 +1,23 @@
|
|||||||
{
|
{
|
||||||
|
"files": {
|
||||||
|
"includes": ["src/**/*.ts", "tests/**/*.ts", "*.json", "*.ts"]
|
||||||
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"indentStyle": "space",
|
"indentStyle": "space",
|
||||||
"indentWidth": 2,
|
"indentWidth": 2
|
||||||
"includes": ["src/**/*.ts", "tests/**/*.ts", "*.json", "*.ts"]
|
|
||||||
},
|
},
|
||||||
"linter": {
|
"linter": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"rules": {
|
"rules": {
|
||||||
"recommended": true
|
"recommended": false,
|
||||||
},
|
"style": {
|
||||||
"includes": ["src/**/*.ts", "tests/**/*.ts", "*.json", "*.ts"]
|
"noNonNullAssertion": "warn",
|
||||||
|
"useNodejsImportProtocol": "warn"
|
||||||
|
},
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
975
docs/superpowers/plans/2026-05-13-aggressive-cleanup.md
Normal file
975
docs/superpowers/plans/2026-05-13-aggressive-cleanup.md
Normal file
@@ -0,0 +1,975 @@
|
|||||||
|
# Aggressive Cleanup Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Clean up Bun/TypeScript Discord voice recorder with Biome, Vitest, stricter types, modular recorder code, and verification scripts.
|
||||||
|
|
||||||
|
**Architecture:** Keep runtime behavior same while moving pure logic into focused modules. `src/recorder.ts` remains public API or re-exports recorder module to minimize import churn; new `src/recorder/*` files own config parsing, metadata creation, decoder lifecycle, segment lifecycle, and stream orchestration.
|
||||||
|
|
||||||
|
**Tech Stack:** Bun, TypeScript strict mode, Biome, Vitest, discord.js-selfbot-v13, @discordjs/voice, prism-media.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Map
|
||||||
|
|
||||||
|
- Modify `package.json`: add scripts and dev dependencies.
|
||||||
|
- Create `biome.json`: formatter/linter config.
|
||||||
|
- Create `vitest.config.ts`: Bun-compatible Vitest config.
|
||||||
|
- Modify `tsconfig.json`: include tests/config if needed, keep strict mode.
|
||||||
|
- Modify `src/config.ts`: typed env/config parsing.
|
||||||
|
- Create `src/types.ts`: shared recorder-facing types.
|
||||||
|
- Create `src/recorder/metadata.ts`: user metadata and segment metadata builders.
|
||||||
|
- Create `src/recorder/decoder.ts`: Opus decoder lifecycle.
|
||||||
|
- Create `src/recorder/segment.ts`: OGG segment lifecycle.
|
||||||
|
- Create `src/recorder/audioStream.ts`: subscribe/event wiring helpers.
|
||||||
|
- Modify `src/recorder.ts`: orchestrator using new modules.
|
||||||
|
- Create `tests/config.test.ts`, `tests/recorder/metadata.test.ts`, `tests/recorder/decoder.test.ts`, `tests/recorder/segment.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Tooling setup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `package.json`
|
||||||
|
- Create: `biome.json`
|
||||||
|
- Create: `vitest.config.ts`
|
||||||
|
- Modify: `tsconfig.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add dependencies**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
bun add -d @biomejs/biome vitest
|
||||||
|
```
|
||||||
|
Expected: `package.json` and lockfile update.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update scripts in `package.json`**
|
||||||
|
|
||||||
|
Set scripts to include:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dev": "bun --watch src/index.ts",
|
||||||
|
"start": "bun src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"test": "vitest run"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `biome.json`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||||
|
"files": {
|
||||||
|
"includes": ["src/**/*.ts", "tests/**/*.ts", "*.json", "*.ts"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"suspicious": {
|
||||||
|
"noExplicitAny": "warn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create `vitest.config.ts`**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: "node",
|
||||||
|
include: ["tests/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify tooling commands**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
bun run typecheck
|
||||||
|
bun run lint
|
||||||
|
bun run test
|
||||||
|
```
|
||||||
|
Expected: typecheck may pass or show existing issues; lint may show format/type warnings; test may pass with no tests or fail if Vitest needs config adjustment. Fix only setup errors in this task.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add package.json bun.lockb biome.json vitest.config.ts tsconfig.json
|
||||||
|
git commit -m "chore: add code quality tooling"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Config parsing tests and implementation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/config.ts`
|
||||||
|
- Create: `tests/config.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests**
|
||||||
|
|
||||||
|
Create `tests/config.test.ts`:
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseBoolean, parsePositiveNumber } from "../src/config";
|
||||||
|
|
||||||
|
describe("config parsers", () => {
|
||||||
|
it("parses boolean values", () => {
|
||||||
|
expect(parseBoolean("true", false)).toBe(true);
|
||||||
|
expect(parseBoolean("false", true)).toBe(false);
|
||||||
|
expect(parseBoolean(undefined, true)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses positive numbers", () => {
|
||||||
|
expect(parsePositiveNumber("5000", 0)).toBe(5000);
|
||||||
|
expect(parsePositiveNumber("0", 123)).toBe(123);
|
||||||
|
expect(parsePositiveNumber("bad", 123)).toBe(123);
|
||||||
|
expect(parsePositiveNumber(undefined, 123)).toBe(123);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
bun run test tests/config.test.ts
|
||||||
|
```
|
||||||
|
Expected: FAIL because `parseBoolean` and `parsePositiveNumber` are not exported.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement config helpers**
|
||||||
|
|
||||||
|
Update `src/config.ts` to export:
|
||||||
|
```ts
|
||||||
|
export interface AppConfig {
|
||||||
|
verbose: boolean;
|
||||||
|
recordingsDir: string;
|
||||||
|
recordingSegmentMs: number;
|
||||||
|
decoderRotateMs: number;
|
||||||
|
decoderCooldownMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBoolean(value: string | undefined, fallback: boolean): boolean {
|
||||||
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePositiveNumber(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 {
|
||||||
|
return {
|
||||||
|
verbose: parseBoolean(env.VERBOSE, false),
|
||||||
|
recordingsDir: env.RECORDINGS_DIR ?? "./recordings",
|
||||||
|
recordingSegmentMs: parsePositiveNumber(env.RECORDING_SEGMENT_MS, 5_000),
|
||||||
|
decoderRotateMs: parsePositiveNumber(env.DECODER_ROTATE_MS, 5_000),
|
||||||
|
decoderCooldownMs: 30_000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = loadConfig();
|
||||||
|
```
|
||||||
|
Preserve any existing exports by folding them into `AppConfig` if needed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run config tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
bun run test tests/config.test.ts
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run typecheck**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS or only unrelated existing errors. Fix config-related errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/config.ts tests/config.test.ts
|
||||||
|
git commit -m "refactor: type application config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Shared recorder types
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/types.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create shared types**
|
||||||
|
|
||||||
|
Create `src/types.ts`:
|
||||||
|
```ts
|
||||||
|
import type fs from "node:fs";
|
||||||
|
import type prism from "prism-media";
|
||||||
|
|
||||||
|
export interface RoleMetadata {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMetadata {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
tag: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
bot: boolean;
|
||||||
|
roles: RoleMetadata[];
|
||||||
|
highestRole: RoleMetadata | null;
|
||||||
|
joinedTimestamp: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentState {
|
||||||
|
index: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number | null;
|
||||||
|
filename: string;
|
||||||
|
jsonFilename: string;
|
||||||
|
oggStream: prism.opus.OggLogicalBitstream;
|
||||||
|
out: fs.WriteStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentMetadata extends UserMetadata {
|
||||||
|
sessionId: string;
|
||||||
|
sessionStartTime: number;
|
||||||
|
segmentIndex: number;
|
||||||
|
segmentMs: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
durationMs: number;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PcmBroadcaster {
|
||||||
|
broadcastPcmToWeb?: (chunk: Buffer, userId: string) => void;
|
||||||
|
updateActiveUser?: (userId: string, data: { username: string; avatar: string; speaking: boolean }) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run typecheck**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS. If prism type export fails, use `unknown` for `oggStream` plus local narrowed calls in segment implementation.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/types.ts
|
||||||
|
git commit -m "refactor: add recorder domain types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Metadata tests and implementation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/recorder/metadata.ts`
|
||||||
|
- Create: `tests/recorder/metadata.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests**
|
||||||
|
|
||||||
|
Create `tests/recorder/metadata.test.ts`:
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createSegmentMetadata } from "../../src/recorder/metadata";
|
||||||
|
import type { SegmentState, UserMetadata } from "../../src/types";
|
||||||
|
|
||||||
|
const user: UserMetadata = {
|
||||||
|
userId: "123",
|
||||||
|
username: "alice",
|
||||||
|
tag: "alice#0001",
|
||||||
|
displayName: "Alice",
|
||||||
|
avatarUrl: "https://cdn.discordapp.com/embed/avatars/0.png",
|
||||||
|
bot: false,
|
||||||
|
roles: [{ id: "role", name: "Admin", position: 1 }],
|
||||||
|
highestRole: { id: "role", name: "Admin", position: 1 },
|
||||||
|
joinedTimestamp: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
const segment = {
|
||||||
|
index: 2,
|
||||||
|
startTime: 1_000,
|
||||||
|
endTime: 2_500,
|
||||||
|
filename: "/tmp/2500.ogg",
|
||||||
|
jsonFilename: "/tmp/2500.json",
|
||||||
|
oggStream: {} as SegmentState["oggStream"],
|
||||||
|
out: {} as SegmentState["out"],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createSegmentMetadata", () => {
|
||||||
|
it("combines user and segment data", () => {
|
||||||
|
const metadata = createSegmentMetadata(user, segment, "session-1", 900, 5_000);
|
||||||
|
|
||||||
|
expect(metadata).toMatchObject({
|
||||||
|
userId: "123",
|
||||||
|
username: "alice",
|
||||||
|
sessionId: "session-1",
|
||||||
|
sessionStartTime: 900,
|
||||||
|
segmentIndex: 2,
|
||||||
|
segmentMs: 5_000,
|
||||||
|
startTime: 1_000,
|
||||||
|
endTime: 2_500,
|
||||||
|
durationMs: 1_500,
|
||||||
|
filename: "2500.ogg",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test tests/recorder/metadata.test.ts
|
||||||
|
```
|
||||||
|
Expected: FAIL because module does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement metadata module**
|
||||||
|
|
||||||
|
Create `src/recorder/metadata.ts`:
|
||||||
|
```ts
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||||
|
import type { SegmentMetadata, SegmentState, UserMetadata } from "../types";
|
||||||
|
|
||||||
|
export async function collectUserMetadata(
|
||||||
|
client: Client,
|
||||||
|
userId: string,
|
||||||
|
channel: VoiceChannel,
|
||||||
|
): Promise<UserMetadata> {
|
||||||
|
const user = client.users.cache.get(userId) ?? (await client.users.fetch(userId).catch(() => null));
|
||||||
|
const member = channel.guild.members.cache.get(userId) ?? (await channel.guild.members.fetch(userId).catch(() => null));
|
||||||
|
const username = user?.username ?? "Unknown User";
|
||||||
|
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 })) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
tag: user?.tag ?? "Unknown#0000",
|
||||||
|
displayName: member?.displayName ?? username,
|
||||||
|
avatarUrl: user?.displayAvatarURL({ format: "png", size: 64 }) ?? "https://cdn.discordapp.com/embed/avatars/0.png",
|
||||||
|
bot: user?.bot ?? false,
|
||||||
|
roles,
|
||||||
|
highestRole: roles[0] ?? null,
|
||||||
|
joinedTimestamp: member?.joinedTimestamp ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSegmentMetadata(
|
||||||
|
user: UserMetadata,
|
||||||
|
segment: SegmentState,
|
||||||
|
sessionId: string,
|
||||||
|
sessionStartTime: number,
|
||||||
|
recordingSegmentMs: number,
|
||||||
|
): SegmentMetadata {
|
||||||
|
const endTime = segment.endTime ?? Date.now();
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
sessionId,
|
||||||
|
sessionStartTime,
|
||||||
|
segmentIndex: segment.index,
|
||||||
|
segmentMs: recordingSegmentMs,
|
||||||
|
startTime: segment.startTime,
|
||||||
|
endTime,
|
||||||
|
durationMs: endTime - segment.startTime,
|
||||||
|
filename: path.basename(segment.filename),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests and typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test tests/recorder/metadata.test.ts
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/recorder/metadata.ts tests/recorder/metadata.test.ts
|
||||||
|
git commit -m "refactor: extract recorder metadata builders"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Decoder tests and implementation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/recorder/decoder.ts`
|
||||||
|
- Create: `tests/recorder/decoder.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests using fake decoder factory**
|
||||||
|
|
||||||
|
Create `tests/recorder/decoder.test.ts`:
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { OpusDecoder } from "../../src/recorder/decoder";
|
||||||
|
|
||||||
|
class FakeDecoder {
|
||||||
|
handlers = new Map<string, (...args: unknown[]) => void>();
|
||||||
|
destroyed = false;
|
||||||
|
writes: Buffer[] = [];
|
||||||
|
|
||||||
|
on(event: string, handler: (...args: unknown[]) => void) {
|
||||||
|
this.handlers.set(event, handler);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
write(chunk: Buffer) {
|
||||||
|
this.writes.push(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeAllListeners() {
|
||||||
|
this.handlers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.destroyed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OpusDecoder", () => {
|
||||||
|
it("creates decoder lazily and writes chunks", () => {
|
||||||
|
const fake = new FakeDecoder();
|
||||||
|
const decoder = new OpusDecoder({ cooldownMs: 30_000, rotateMs: 5_000, createDecoder: () => fake as never, onData: vi.fn() });
|
||||||
|
|
||||||
|
decoder.write(Buffer.from([1, 2, 3]));
|
||||||
|
|
||||||
|
expect(fake.writes).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys and recreates after rotation timeout", () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const created: FakeDecoder[] = [];
|
||||||
|
const decoder = new OpusDecoder({
|
||||||
|
cooldownMs: 30_000,
|
||||||
|
rotateMs: 5_000,
|
||||||
|
createDecoder: () => {
|
||||||
|
const fake = new FakeDecoder();
|
||||||
|
created.push(fake);
|
||||||
|
return fake as never;
|
||||||
|
},
|
||||||
|
onData: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
decoder.write(Buffer.from([1]));
|
||||||
|
vi.advanceTimersByTime(5_001);
|
||||||
|
decoder.rotateIfNeeded();
|
||||||
|
decoder.write(Buffer.from([2]));
|
||||||
|
|
||||||
|
expect(created).toHaveLength(2);
|
||||||
|
expect(created[0].destroyed).toBe(true);
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test tests/recorder/decoder.test.ts
|
||||||
|
```
|
||||||
|
Expected: FAIL because module does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement decoder module**
|
||||||
|
|
||||||
|
Create `src/recorder/decoder.ts`:
|
||||||
|
```ts
|
||||||
|
import prism from "prism-media";
|
||||||
|
|
||||||
|
export interface OpusDecoderOptions {
|
||||||
|
cooldownMs: number;
|
||||||
|
rotateMs: number;
|
||||||
|
createDecoder?: () => prism.opus.Decoder;
|
||||||
|
onData: (pcm: Buffer) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OpusDecoder {
|
||||||
|
private decoder: prism.opus.Decoder | null = null;
|
||||||
|
private disabledUntil = 0;
|
||||||
|
private createdAt = 0;
|
||||||
|
private readonly cooldownMs: number;
|
||||||
|
private readonly rotateMs: number;
|
||||||
|
private readonly createDecoderFn: () => prism.opus.Decoder;
|
||||||
|
private readonly onData: (pcm: Buffer) => void;
|
||||||
|
|
||||||
|
constructor(options: OpusDecoderOptions) {
|
||||||
|
this.cooldownMs = options.cooldownMs;
|
||||||
|
this.rotateMs = options.rotateMs;
|
||||||
|
this.onData = options.onData;
|
||||||
|
this.createDecoderFn =
|
||||||
|
options.createDecoder ??
|
||||||
|
(() => new prism.opus.Decoder({ frameSize: 960, channels: 2, rate: 48_000 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
rotateIfNeeded(): void {
|
||||||
|
if (!this.decoder || this.rotateMs <= 0) return;
|
||||||
|
if (Date.now() - this.createdAt < this.rotateMs) return;
|
||||||
|
this.destroy();
|
||||||
|
this.ensureDecoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
write(chunk: Buffer): void {
|
||||||
|
const decoder = this.ensureDecoder();
|
||||||
|
if (!decoder) return;
|
||||||
|
try {
|
||||||
|
decoder.write(chunk);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[recorder] Opus decoder write failed, cooling down:", error);
|
||||||
|
this.coolDown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (!this.decoder) return;
|
||||||
|
this.decoder.removeAllListeners();
|
||||||
|
this.decoder.destroy();
|
||||||
|
this.decoder = null;
|
||||||
|
this.createdAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDecoder(): prism.opus.Decoder | null {
|
||||||
|
if (this.decoder) return this.decoder;
|
||||||
|
if (Date.now() < this.disabledUntil) return null;
|
||||||
|
try {
|
||||||
|
const decoder = this.createDecoderFn();
|
||||||
|
decoder.on("data", this.onData);
|
||||||
|
decoder.on("error", (error) => {
|
||||||
|
console.warn("[recorder] Opus decoder error, cooling down:", error);
|
||||||
|
this.coolDown();
|
||||||
|
});
|
||||||
|
this.decoder = decoder;
|
||||||
|
this.createdAt = Date.now();
|
||||||
|
return decoder;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[recorder] Opus decoder init failed, cooling down:", error);
|
||||||
|
this.disabledUntil = Date.now() + this.cooldownMs;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private coolDown(): void {
|
||||||
|
this.disabledUntil = Date.now() + this.cooldownMs;
|
||||||
|
this.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests and typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test tests/recorder/decoder.test.ts
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/recorder/decoder.ts tests/recorder/decoder.test.ts
|
||||||
|
git commit -m "refactor: extract opus decoder lifecycle"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Segment tests and implementation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/recorder/segment.ts`
|
||||||
|
- Create: `tests/recorder/segment.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write tests for pure filename and rotation decision helpers**
|
||||||
|
|
||||||
|
Create `tests/recorder/segment.test.ts`:
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildSegmentPaths, shouldRotateSegment } from "../../src/recorder/segment";
|
||||||
|
|
||||||
|
describe("buildSegmentPaths", () => {
|
||||||
|
it("creates matching ogg and json paths", () => {
|
||||||
|
expect(buildSegmentPaths("/tmp/user", 123)).toEqual({
|
||||||
|
filename: "/tmp/user/123.ogg",
|
||||||
|
jsonFilename: "/tmp/user/123.json",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldRotateSegment", () => {
|
||||||
|
it("rotates only when segment limit is exceeded", () => {
|
||||||
|
expect(shouldRotateSegment(1_000, 1_499, 500)).toBe(false);
|
||||||
|
expect(shouldRotateSegment(1_000, 1_500, 500)).toBe(true);
|
||||||
|
expect(shouldRotateSegment(1_000, 2_000, 0)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run failing test**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test tests/recorder/segment.test.ts
|
||||||
|
```
|
||||||
|
Expected: FAIL because module does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement segment helpers and manager**
|
||||||
|
|
||||||
|
Create `src/recorder/segment.ts`:
|
||||||
|
```ts
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import prism from "prism-media";
|
||||||
|
import type { SegmentState } from "../types";
|
||||||
|
|
||||||
|
export function buildSegmentPaths(userDir: string, startTime: number): { filename: string; jsonFilename: string } {
|
||||||
|
return {
|
||||||
|
filename: path.join(userDir, `${startTime}.ogg`),
|
||||||
|
jsonFilename: path.join(userDir, `${startTime}.json`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRotateSegment(startTime: number, now: number, recordingSegmentMs: number): boolean {
|
||||||
|
return recordingSegmentMs > 0 && now - startTime >= recordingSegmentMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SegmentManager {
|
||||||
|
private currentSegment: SegmentState | null = null;
|
||||||
|
private segmentIndex = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly userDir: string,
|
||||||
|
private readonly recordingSegmentMs: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
open(oggPacketStream: NodeJS.ReadableStream): SegmentState {
|
||||||
|
const index = this.segmentIndex++;
|
||||||
|
const startTime = Date.now();
|
||||||
|
const { filename, jsonFilename } = buildSegmentPaths(this.userDir, startTime);
|
||||||
|
const oggStream = new prism.opus.OggLogicalBitstream({
|
||||||
|
opusHead: new prism.opus.OpusHead({ channelCount: 2, sampleRate: 48_000 }),
|
||||||
|
pageSizeControl: { maxPackets: 10 },
|
||||||
|
crc: true,
|
||||||
|
});
|
||||||
|
const out = fs.createWriteStream(filename);
|
||||||
|
oggPacketStream.pipe(oggStream).pipe(out);
|
||||||
|
|
||||||
|
this.currentSegment = { index, startTime, endTime: null, filename, jsonFilename, oggStream, out };
|
||||||
|
return this.currentSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
close(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
|
||||||
|
if (!this.currentSegment) return null;
|
||||||
|
const segment = this.currentSegment;
|
||||||
|
segment.endTime = Date.now();
|
||||||
|
oggPacketStream.unpipe(segment.oggStream);
|
||||||
|
segment.oggStream.end();
|
||||||
|
this.currentSegment = null;
|
||||||
|
return segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotateIfNeeded(oggPacketStream: NodeJS.ReadableStream): SegmentState | null {
|
||||||
|
if (!this.currentSegment) return null;
|
||||||
|
if (!shouldRotateSegment(this.currentSegment.startTime, Date.now(), this.recordingSegmentMs)) return null;
|
||||||
|
this.close(oggPacketStream);
|
||||||
|
return this.open(oggPacketStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrent(): SegmentState | null {
|
||||||
|
return this.currentSegment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests and typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test tests/recorder/segment.test.ts
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/recorder/segment.ts tests/recorder/segment.test.ts
|
||||||
|
git commit -m "refactor: extract recording segment manager"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Audio stream helper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/recorder/audioStream.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create stream helper**
|
||||||
|
|
||||||
|
Create `src/recorder/audioStream.ts`:
|
||||||
|
```ts
|
||||||
|
import { EndBehaviorType, type VoiceReceiver } from "@discordjs/voice";
|
||||||
|
|
||||||
|
export interface AudioStreamHandlers {
|
||||||
|
onPacket: (chunk: Buffer) => void;
|
||||||
|
onEnd: () => void;
|
||||||
|
onError: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeToAudioStream(
|
||||||
|
receiver: VoiceReceiver,
|
||||||
|
userId: string,
|
||||||
|
handlers: AudioStreamHandlers,
|
||||||
|
): NodeJS.ReadableStream {
|
||||||
|
const audioStream = receiver.subscribe(userId, {
|
||||||
|
end: {
|
||||||
|
behavior: EndBehaviorType.AfterSilence,
|
||||||
|
duration: 3_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
audioStream.on("data", handlers.onPacket);
|
||||||
|
audioStream.on("end", handlers.onEnd);
|
||||||
|
audioStream.on("error", handlers.onError);
|
||||||
|
|
||||||
|
return audioStream;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/recorder/audioStream.ts
|
||||||
|
git commit -m "refactor: extract audio stream subscription"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Refactor `src/recorder.ts` orchestration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/recorder.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace inline helpers with modules**
|
||||||
|
|
||||||
|
Edit `src/recorder.ts`:
|
||||||
|
- Import `collectUserMetadata`, `createSegmentMetadata`.
|
||||||
|
- Import `OpusDecoder`.
|
||||||
|
- Import `SegmentManager`.
|
||||||
|
- Import `subscribeToAudioStream`.
|
||||||
|
- Use `config.recordingsDir`, `config.recordingSegmentMs`, `config.decoderRotateMs`, `config.decoderCooldownMs`.
|
||||||
|
- Keep `startRecording(client, channel)` and `stopRecording(guildId)` exports unchanged.
|
||||||
|
- Remove packet debug logging `Pkt #...`.
|
||||||
|
- Keep current global web update behavior via `globalThis as PcmBroadcaster`.
|
||||||
|
|
||||||
|
Core packet handler shape:
|
||||||
|
```ts
|
||||||
|
const broadcaster = globalThis as typeof globalThis & PcmBroadcaster;
|
||||||
|
const userMetadata = await collectUserMetadata(client, userId, channel);
|
||||||
|
const segmentManager = new SegmentManager(userDir, config.recordingSegmentMs);
|
||||||
|
const decoder = new OpusDecoder({
|
||||||
|
cooldownMs: config.decoderCooldownMs,
|
||||||
|
rotateMs: config.decoderRotateMs,
|
||||||
|
onData: (pcm) => {
|
||||||
|
if (!broadcaster.broadcastPcmToWeb) return;
|
||||||
|
const outBuf = Buffer.alloc(pcm.length / 4);
|
||||||
|
for (let i = 0; i < outBuf.length / 2; i++) {
|
||||||
|
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
|
||||||
|
}
|
||||||
|
broadcaster.broadcastPcmToWeb(outBuf, userId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioStream = subscribeToAudioStream(receiver, userId, {
|
||||||
|
onPacket: (chunk) => {
|
||||||
|
if (chunk.length < 8) return;
|
||||||
|
segmentManager.rotateIfNeeded(oggPacketStream);
|
||||||
|
if (!broadcaster.broadcastPcmToWeb) return;
|
||||||
|
decoder.rotateIfNeeded();
|
||||||
|
decoder.write(chunk);
|
||||||
|
},
|
||||||
|
onEnd: () => {
|
||||||
|
const segment = segmentManager.close(oggPacketStream);
|
||||||
|
decoder.destroy();
|
||||||
|
if (segment) {
|
||||||
|
const metadata = createSegmentMetadata(userMetadata, segment, sessionId, sessionStartTime, config.recordingSegmentMs);
|
||||||
|
fs.writeFileSync(segment.jsonFilename, JSON.stringify(metadata, null, 2));
|
||||||
|
}
|
||||||
|
broadcaster.updateActiveUser?.(userId, { username: userMetadata.username, avatar: userMetadata.avatarUrl, speaking: false });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
segmentManager.close(oggPacketStream);
|
||||||
|
decoder.destroy();
|
||||||
|
console.error(`[recorder] Audio Stream error ${userId}:`, error.message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Preserve metadata writes on segment finish**
|
||||||
|
|
||||||
|
If existing behavior writes JSON when `out` finishes, attach `out.on("finish", ...)` in `SegmentManager.open()` caller after opening current segment. Ensure every closed segment gets JSON metadata, including rotated segments.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run typecheck**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
Expected: PASS. Fix type errors by narrowing types, not adding `any` unless third-party library lacks exported type.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run test
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/recorder.ts src/recorder/audioStream.ts src/recorder/decoder.ts src/recorder/metadata.ts src/recorder/segment.ts src/types.ts
|
||||||
|
git commit -m "refactor: modularize recorder orchestration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Format and lint cleanup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: all formatted TypeScript/config files touched by Biome.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run formatter**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
```
|
||||||
|
Expected: files formatted.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run linter**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run lint
|
||||||
|
```
|
||||||
|
Expected: PASS or actionable warnings. Fix warnings that are in touched code.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run typecheck and tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run typecheck
|
||||||
|
bun run test
|
||||||
|
```
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "style: format and lint codebase"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Manual runtime verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No required code changes unless verification finds bug.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Start app**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
bun run start
|
||||||
|
```
|
||||||
|
Expected: app starts, logs bot ready or fails only due missing env credentials.
|
||||||
|
|
||||||
|
- [ ] **Step 2: If env exists, verify recording flow**
|
||||||
|
|
||||||
|
Manual steps:
|
||||||
|
1. Join configured Discord voice channel.
|
||||||
|
2. Speak for >3 seconds.
|
||||||
|
3. Confirm `.ogg` file and `.json` metadata are created under `RECORDINGS_DIR`.
|
||||||
|
4. Keep speaking past `RECORDING_SEGMENT_MS`; confirm segment rotation creates multiple files.
|
||||||
|
5. Stop app with Ctrl-C; confirm graceful shutdown log.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit fixes if needed**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src tests package.json biome.json vitest.config.ts tsconfig.json
|
||||||
|
git commit -m "fix: preserve recorder runtime behavior"
|
||||||
|
```
|
||||||
|
Only run if code changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No changes expected.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run full verification**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run format
|
||||||
|
bun run lint
|
||||||
|
bun run typecheck
|
||||||
|
bun run test
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
Expected: formatter stable, lint PASS, typecheck PASS, tests PASS, git status clean or only intentional uncommitted runtime artifacts excluded by `.gitignore`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Review diff summary**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log --oneline -8
|
||||||
|
git diff HEAD~8...HEAD --stat
|
||||||
|
```
|
||||||
|
Expected: commits show tooling, config, types, metadata, decoder, segment, stream, recorder refactor, formatting.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Report result**
|
||||||
|
|
||||||
|
Report:
|
||||||
|
- Commands run and pass/fail status.
|
||||||
|
- Runtime verification status.
|
||||||
|
- Any remaining risks, especially Discord runtime behavior if not manually tested with credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: tooling, config, shared types, metadata, decoder, segment, audio stream, recorder orchestration, tests, lint/format, and runtime verification are covered.
|
||||||
|
- Placeholder scan: no TBD/TODO placeholders.
|
||||||
|
- Type consistency: `UserMetadata`, `SegmentState`, `SegmentMetadata`, `PcmBroadcaster`, `OpusDecoder`, `SegmentManager`, and `subscribeToAudioStream` names are consistent across tasks.
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
"dev": "bun --watch src/index.ts",
|
"dev": "bun --watch src/index.ts",
|
||||||
"start": "bun src/index.ts",
|
"start": "bun src/index.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "biome check .",
|
"lint": "biome check --diagnostic-level=error .",
|
||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Configuration for the bot
|
// Configuration for the bot
|
||||||
export const config = {
|
export const config = {
|
||||||
verbose: process.argv.includes('-v') || process.argv.includes('--verbose'),
|
verbose: process.argv.includes("-v") || process.argv.includes("--verbose"),
|
||||||
};
|
};
|
||||||
|
|||||||
96
src/index.ts
96
src/index.ts
@@ -1,12 +1,12 @@
|
|||||||
import "./mock-crc";
|
import "./mock-crc";
|
||||||
import "libsodium-wrappers";
|
import "libsodium-wrappers";
|
||||||
import "@snazzah/davey";
|
import "@snazzah/davey";
|
||||||
import { Client } from "discord.js-selfbot-v13";
|
|
||||||
import { startRecording } from "./recorder";
|
|
||||||
import { config } from "./config";
|
|
||||||
import { startWebserver } from "./webserver";
|
|
||||||
import { discordPlayer } from "./player";
|
|
||||||
import { getVoiceConnection } from "@discordjs/voice";
|
import { getVoiceConnection } from "@discordjs/voice";
|
||||||
|
import { Client } from "discord.js-selfbot-v13";
|
||||||
|
import { config } from "./config";
|
||||||
|
import { discordPlayer } from "./player";
|
||||||
|
import { startRecording } from "./recorder";
|
||||||
|
import { startWebserver } from "./webserver";
|
||||||
|
|
||||||
// Validasi environment variables
|
// Validasi environment variables
|
||||||
const token = process.env.DISCORD_TOKEN;
|
const token = process.env.DISCORD_TOKEN;
|
||||||
@@ -21,64 +21,66 @@ if (!guildId) throw new Error("Missing GUILD_ID in .env");
|
|||||||
const client = new Client();
|
const client = new Client();
|
||||||
|
|
||||||
client.on("ready", async () => {
|
client.on("ready", async () => {
|
||||||
if (config.verbose) {
|
if (config.verbose) {
|
||||||
console.log(`[bot] Logged in as ${client.user!.tag}`);
|
console.log(`[bot] Logged in as ${client.user!.tag}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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}`);
|
console.error(`[bot] Guild not found: ${guildId}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch channels jika belum ada di cache
|
// Fetch channels jika belum ada di cache
|
||||||
const channel =
|
const channel =
|
||||||
guild.channels.cache.get(voiceChannelId!) ??
|
guild.channels.cache.get(voiceChannelId!) ??
|
||||||
(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(
|
console.error(
|
||||||
`[bot] Voice channel not found or wrong type: ${voiceChannelId}`
|
`[bot] Voice channel not found or wrong type: ${voiceChannelId}`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.verbose) {
|
if (config.verbose) {
|
||||||
console.log(`[bot] Joining voice channel: #${channel.name} (${channel.id})`);
|
console.log(
|
||||||
}
|
`[bot] Joining voice channel: #${channel.name} (${channel.id})`,
|
||||||
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");
|
console.log("[bot] Player connected to voice channel");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start Webserver
|
// Start Webserver
|
||||||
startWebserver(3000);
|
startWebserver(3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on("error", (err) => {
|
client.on("error", (err) => {
|
||||||
console.error("[bot] Client error:", err);
|
console.error("[bot] Client error:", err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
if (config.verbose) {
|
if (config.verbose) {
|
||||||
console.log("\n[bot] Shutting down...");
|
console.log("\n[bot] Shutting down...");
|
||||||
}
|
}
|
||||||
client.destroy();
|
client.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
if (config.verbose) {
|
if (config.verbose) {
|
||||||
console.log("[bot] Terminating...");
|
console.log("[bot] Terminating...");
|
||||||
}
|
}
|
||||||
client.destroy();
|
client.destroy();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.login(token);
|
client.login(token);
|
||||||
|
|||||||
@@ -1,31 +1,42 @@
|
|||||||
// Mock node-crc to provide pure JS implementation and bypass native build issues
|
// Mock node-crc to provide pure JS implementation and bypass native build issues
|
||||||
const CRC_TABLE = new Uint32Array(256);
|
const CRC_TABLE = new Uint32Array(256);
|
||||||
for (let i = 0; i < 256; i++) {
|
for (let i = 0; i < 256; i++) {
|
||||||
let r = i << 24;
|
let r = i << 24;
|
||||||
for (let j = 0; j < 8; j++) {
|
for (let j = 0; j < 8; j++) {
|
||||||
r = (r & 0x80000000) !== 0 ? ((r << 1) ^ 0x04c11db7) : (r << 1);
|
r = (r & 0x80000000) !== 0 ? (r << 1) ^ 0x04c11db7 : r << 1;
|
||||||
}
|
}
|
||||||
CRC_TABLE[i] = (r >>> 0);
|
CRC_TABLE[i] = r >>> 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Module = require('module');
|
const Module = require("module");
|
||||||
const originalRequire = Module.prototype.require;
|
const originalRequire = Module.prototype.require;
|
||||||
Module.prototype.require = function (id: string) {
|
Module.prototype.require = function (id: string) {
|
||||||
if (id === 'node-crc') {
|
if (id === "node-crc") {
|
||||||
return {
|
return {
|
||||||
crc: function (width: number, reflectIn: boolean, poly: number, init: number, refOut: boolean, xorOut: number, unk1: number, unk2: number, buffer: Buffer) {
|
crc: function (
|
||||||
let crc = 0;
|
width: number,
|
||||||
for (let i = 0; i < buffer.length; i++) {
|
reflectIn: boolean,
|
||||||
crc = ((crc << 8) >>> 0) ^ CRC_TABLE[((crc >>> 24) ^ buffer[i]) & 0xff];
|
poly: number,
|
||||||
crc >>>= 0;
|
init: number,
|
||||||
}
|
refOut: boolean,
|
||||||
const result = Buffer.alloc(4);
|
xorOut: number,
|
||||||
result.writeUInt32BE(crc, 0);
|
unk1: number,
|
||||||
return result;
|
unk2: number,
|
||||||
}
|
buffer: Buffer,
|
||||||
};
|
) {
|
||||||
}
|
let crc = 0;
|
||||||
return originalRequire.apply(this, arguments);
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
crc =
|
||||||
|
((crc << 8) >>> 0) ^ CRC_TABLE[((crc >>> 24) ^ buffer[i]) & 0xff];
|
||||||
|
crc >>>= 0;
|
||||||
|
}
|
||||||
|
const result = Buffer.alloc(4);
|
||||||
|
result.writeUInt32BE(crc, 0);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return originalRequire.apply(this, arguments);
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("[mock] node-crc has been mocked globally.");
|
console.log("[mock] node-crc has been mocked globally.");
|
||||||
|
|||||||
@@ -1,178 +1,212 @@
|
|||||||
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import ffmpeg from "fluent-ffmpeg";
|
|
||||||
|
|
||||||
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||||
|
|
||||||
interface EventMetadata {
|
interface EventMetadata {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
bot?: boolean;
|
bot?: boolean;
|
||||||
roles?: Array<{ id: string; name: string; position: number }>;
|
roles?: Array<{ id: string; name: string; position: number }>;
|
||||||
highestRole?: { id: string; name: string; position: number } | null;
|
highestRole?: { id: string; name: string; position: number } | null;
|
||||||
joinedTimestamp?: number | null;
|
joinedTimestamp?: number | null;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
sessionStartTime?: number;
|
sessionStartTime?: number;
|
||||||
segmentIndex?: number;
|
segmentIndex?: number;
|
||||||
segmentMs?: number;
|
segmentMs?: number;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClipInfo {
|
interface ClipInfo {
|
||||||
oggPath: string;
|
oggPath: string;
|
||||||
jsonPath: string;
|
jsonPath: string;
|
||||||
meta: EventMetadata;
|
meta: EventMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startMuxingToAup3() {
|
async function startMuxingToAup3() {
|
||||||
console.log("[muxer-aup3] Scanning recordings directory...");
|
console.log("[muxer-aup3] Scanning recordings directory...");
|
||||||
if (!fs.existsSync(recordingsDir)) {
|
if (!fs.existsSync(recordingsDir)) {
|
||||||
console.error("[muxer-aup3] Recordings directory not found.");
|
console.error("[muxer-aup3] Recordings directory not found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clips: ClipInfo[] = [];
|
const clips: ClipInfo[] = [];
|
||||||
|
|
||||||
// Scan user directories
|
// Scan user directories
|
||||||
const items = fs.readdirSync(recordingsDir);
|
const items = fs.readdirSync(recordingsDir);
|
||||||
console.log(`[muxer-aup3] Found ${items.length} directories to scan...`);
|
console.log(`[muxer-aup3] Found ${items.length} directories to scan...`);
|
||||||
|
|
||||||
let processedDirs = 0;
|
let processedDirs = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const itemPath = path.join(recordingsDir, item);
|
const itemPath = path.join(recordingsDir, item);
|
||||||
if (fs.statSync(itemPath).isDirectory()) {
|
if (fs.statSync(itemPath).isDirectory()) {
|
||||||
const files = fs.readdirSync(itemPath);
|
const files = fs.readdirSync(itemPath);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith(".json")) {
|
if (file.endsWith(".json")) {
|
||||||
const jsonPath = path.join(itemPath, file);
|
const jsonPath = path.join(itemPath, file);
|
||||||
const oggPath = jsonPath.replace(/\.json$/, ".ogg");
|
const oggPath = jsonPath.replace(/\.json$/, ".ogg");
|
||||||
if (fs.existsSync(oggPath)) {
|
if (fs.existsSync(oggPath)) {
|
||||||
try {
|
try {
|
||||||
// Check if OGG file is valid (not empty and has reasonable size)
|
// Check if OGG file is valid (not empty and has reasonable size)
|
||||||
const oggStats = fs.statSync(oggPath);
|
const oggStats = fs.statSync(oggPath);
|
||||||
if (oggStats.size === 0) {
|
if (oggStats.size === 0) {
|
||||||
console.warn(`[muxer-aup3] Skipping empty OGG file: ${oggPath}`);
|
console.warn(
|
||||||
continue;
|
`[muxer-aup3] Skipping empty OGG file: ${oggPath}`,
|
||||||
}
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip files that are too small (less than 1KB likely corrupted)
|
// Skip files that are too small (less than 1KB likely corrupted)
|
||||||
if (oggStats.size < 1024) {
|
if (oggStats.size < 1024) {
|
||||||
console.warn(`[muxer-aup3] Skipping too small OGG file (${oggStats.size} bytes): ${oggPath}`);
|
console.warn(
|
||||||
continue;
|
`[muxer-aup3] Skipping too small OGG file (${oggStats.size} bytes): ${oggPath}`,
|
||||||
}
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if OGG file has valid header (starts with "OggS")
|
// Check if OGG file has valid header (starts with "OggS")
|
||||||
const oggBuffer = fs.readFileSync(oggPath);
|
const oggBuffer = fs.readFileSync(oggPath);
|
||||||
const oggHeader = oggBuffer.slice(0, 4).toString();
|
const oggHeader = oggBuffer.slice(0, 4).toString();
|
||||||
if (oggHeader !== "OggS") {
|
if (oggHeader !== "OggS") {
|
||||||
console.warn(`[muxer-aup3] Skipping invalid OGG file (bad header): ${oggPath}`);
|
console.warn(
|
||||||
continue;
|
`[muxer-aup3] Skipping invalid OGG file (bad header): ${oggPath}`,
|
||||||
}
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const meta: EventMetadata = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
const meta: EventMetadata = JSON.parse(
|
||||||
clips.push({ oggPath, jsonPath, meta });
|
fs.readFileSync(jsonPath, "utf-8"),
|
||||||
} catch (e) {
|
);
|
||||||
console.error(`[muxer-aup3] Failed to read/parse JSON: ${jsonPath}`, e);
|
clips.push({ oggPath, jsonPath, meta });
|
||||||
}
|
} catch (e) {
|
||||||
}
|
console.error(
|
||||||
}
|
`[muxer-aup3] Failed to read/parse JSON: ${jsonPath}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
processedDirs++;
|
}
|
||||||
const progress = ((processedDirs / items.length) * 100).toFixed(2);
|
|
||||||
console.log(`[muxer-aup3] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
processedDirs++;
|
||||||
|
const progress = ((processedDirs / items.length) * 100).toFixed(2);
|
||||||
|
console.log(
|
||||||
|
`[muxer-aup3] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (clips.length === 0) {
|
if (clips.length === 0) {
|
||||||
console.log("[muxer-aup3] No recording clips found to mux.");
|
console.log("[muxer-aup3] No recording clips found to mux.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by startTime so chronologically they are in order
|
// Sort by startTime so chronologically they are in order
|
||||||
clips.sort((a, b) => a.meta.startTime - b.meta.startTime);
|
clips.sort((a, b) => a.meta.startTime - b.meta.startTime);
|
||||||
|
|
||||||
// Find the global start time
|
// Find the global start time
|
||||||
const globalStartTime = clips[0].meta.startTime;
|
const globalStartTime = clips[0].meta.startTime;
|
||||||
console.log(`[muxer-aup3] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`);
|
console.log(
|
||||||
|
`[muxer-aup3] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
|
||||||
|
);
|
||||||
|
|
||||||
const command = ffmpeg();
|
const command = ffmpeg();
|
||||||
const filterParts: string[] = [];
|
const filterParts: string[] = [];
|
||||||
|
|
||||||
console.log(`[muxer-aup3] Creating audio filters for ${clips.length} clips...`);
|
console.log(
|
||||||
clips.forEach((clip, index) => {
|
`[muxer-aup3] Creating audio filters for ${clips.length} clips...`,
|
||||||
command.input(clip.oggPath);
|
);
|
||||||
|
clips.forEach((clip, index) => {
|
||||||
|
command.input(clip.oggPath);
|
||||||
|
|
||||||
// Calculate delay relative to the global start time
|
// Calculate delay relative to the global start time
|
||||||
const delayMs = clip.meta.startTime - globalStartTime;
|
const delayMs = clip.meta.startTime - globalStartTime;
|
||||||
|
|
||||||
// FFmpeg filter structure: [0:a]adelay=1000|1000[a0]
|
// FFmpeg filter structure: [0:a]adelay=1000|1000[a0]
|
||||||
// Setting adelay multiple times covers stereo channels.
|
// Setting adelay multiple times covers stereo channels.
|
||||||
// We ensure all multiple channels get delayed.
|
// We ensure all multiple channels get delayed.
|
||||||
const inputSpecifier = `[${index}:a]`;
|
const inputSpecifier = `[${index}:a]`;
|
||||||
const outputSpecifier = `[pad${index}]`;
|
const outputSpecifier = `[pad${index}]`;
|
||||||
|
|
||||||
filterParts.push(`${inputSpecifier}adelay=${delayMs}|${delayMs}${outputSpecifier}`);
|
filterParts.push(
|
||||||
|
`${inputSpecifier}adelay=${delayMs}|${delayMs}${outputSpecifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
const progress = (((index + 1) / clips.length) * 100).toFixed(2);
|
const progress = (((index + 1) / clips.length) * 100).toFixed(2);
|
||||||
console.log(`[muxer-aup3] Filter creation progress: ${progress}% (${index + 1}/${clips.length} clips)`);
|
console.log(
|
||||||
|
`[muxer-aup3] Filter creation progress: ${progress}% (${index + 1}/${clips.length} clips)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge them using amix
|
||||||
|
const amixInputs = clips.map((_, i) => `[pad${i}]`).join("");
|
||||||
|
// We add the amix command. dropout_transition=0 avoids volume drop when streams end.
|
||||||
|
filterParts.push(
|
||||||
|
`${amixInputs}amix=inputs=${clips.length}:dropout_transition=0[out]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const wavFilename = path.join(recordingsDir, `muxed-${timestamp}.wav`);
|
||||||
|
const aup3Filename = path.join(recordingsDir, `muxed-${timestamp}.aup3`);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[muxer-aup3] Combining clips to WAV. This might take a while...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Using fluent-ffmpeg's complexFilter
|
||||||
|
command
|
||||||
|
.complexFilter(filterParts, "out")
|
||||||
|
.audioCodec("pcm_s16le")
|
||||||
|
.audioFrequency(44100)
|
||||||
|
.audioChannels(2)
|
||||||
|
.save(wavFilename)
|
||||||
|
.on("progress", (progress) => {
|
||||||
|
if (progress.percent) {
|
||||||
|
console.log(
|
||||||
|
`[muxer-aup3] WAV Progress: ${progress.percent.toFixed(2)}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("end", () => {
|
||||||
|
console.log(`[muxer-aup3] WAV file created: ${wavFilename}`);
|
||||||
|
console.log(`[muxer-aup3] Creating AUP3 project file...`);
|
||||||
|
createAup3Project(wavFilename, aup3Filename, clips, globalStartTime);
|
||||||
|
})
|
||||||
|
.on("error", (err) => {
|
||||||
|
console.error(`[muxer-aup3] FFmpeg Error:`, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge them using amix
|
|
||||||
const amixInputs = clips.map((_, i) => `[pad${i}]`).join("");
|
|
||||||
// We add the amix command. dropout_transition=0 avoids volume drop when streams end.
|
|
||||||
filterParts.push(`${amixInputs}amix=inputs=${clips.length}:dropout_transition=0[out]`);
|
|
||||||
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const wavFilename = path.join(recordingsDir, `muxed-${timestamp}.wav`);
|
|
||||||
const aup3Filename = path.join(recordingsDir, `muxed-${timestamp}.aup3`);
|
|
||||||
|
|
||||||
console.log(`[muxer-aup3] Combining clips to WAV. This might take a while...`);
|
|
||||||
|
|
||||||
// Using fluent-ffmpeg's complexFilter
|
|
||||||
command
|
|
||||||
.complexFilter(filterParts, "out")
|
|
||||||
.audioCodec("pcm_s16le")
|
|
||||||
.audioFrequency(44100)
|
|
||||||
.audioChannels(2)
|
|
||||||
.save(wavFilename)
|
|
||||||
.on("progress", (progress) => {
|
|
||||||
if (progress.percent) {
|
|
||||||
console.log(`[muxer-aup3] WAV Progress: ${progress.percent.toFixed(2)}%`);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on("end", () => {
|
|
||||||
console.log(`[muxer-aup3] WAV file created: ${wavFilename}`);
|
|
||||||
console.log(`[muxer-aup3] Creating AUP3 project file...`);
|
|
||||||
createAup3Project(wavFilename, aup3Filename, clips, globalStartTime);
|
|
||||||
})
|
|
||||||
.on("error", (err) => {
|
|
||||||
console.error(`[muxer-aup3] FFmpeg Error:`, err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAup3Project(wavFilename: string, aup3Filename: string, clips: ClipInfo[], globalStartTime: number) {
|
function createAup3Project(
|
||||||
try {
|
wavFilename: string,
|
||||||
console.log(`[muxer-aup3] AUP3 Progress: Reading WAV file...`);
|
aup3Filename: string,
|
||||||
|
clips: ClipInfo[],
|
||||||
|
globalStartTime: number,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
console.log(`[muxer-aup3] AUP3 Progress: Reading WAV file...`);
|
||||||
|
|
||||||
// Read WAV file to get duration
|
// Read WAV file to get duration
|
||||||
const wavStats = fs.statSync(wavFilename);
|
const wavStats = fs.statSync(wavFilename);
|
||||||
const wavSize = wavStats.size;
|
const wavSize = wavStats.size;
|
||||||
|
|
||||||
// Calculate approximate duration (assuming 44.1kHz, 16-bit, stereo)
|
// Calculate approximate duration (assuming 44.1kHz, 16-bit, stereo)
|
||||||
// Duration = (file_size - 44) / (44100 * 2 * 2) for WAV
|
// Duration = (file_size - 44) / (44100 * 2 * 2) for WAV
|
||||||
const duration = (wavSize - 44) / (44100 * 4);
|
const duration = (wavSize - 44) / (44100 * 4);
|
||||||
|
|
||||||
console.log(`[muxer-aup3] AUP3 Progress: Calculating duration... ${duration.toFixed(2)}s`);
|
console.log(
|
||||||
console.log(`[muxer-aup3] AUP3 Progress: Creating XML structure...`);
|
`[muxer-aup3] AUP3 Progress: Calculating duration... ${duration.toFixed(2)}s`,
|
||||||
|
);
|
||||||
|
console.log(`[muxer-aup3] AUP3 Progress: Creating XML structure...`);
|
||||||
|
|
||||||
// Create AUP3 project XML structure
|
// Create AUP3 project XML structure
|
||||||
const aup3Content = `<?xml version="1.0" encoding="UTF-8"?>
|
const aup3Content = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<audacityproject xmlns="http://audacity.sourceforge.net/xml/" projname="muxed" version="1.3.0" audacityversion="3.5.1">
|
<audacityproject xmlns="http://audacity.sourceforge.net/xml/" projname="muxed" version="1.3.0" audacityversion="3.5.1">
|
||||||
<tags>
|
<tags>
|
||||||
<tag name="GENRE" value=""/>
|
<tag name="GENRE" value=""/>
|
||||||
@@ -197,39 +231,40 @@ function createAup3Project(wavFilename: string, aup3Filename: string, clips: Cli
|
|||||||
</timetrack>
|
</timetrack>
|
||||||
</audacityproject>`;
|
</audacityproject>`;
|
||||||
|
|
||||||
console.log(`[muxer-aup3] AUP3 Progress: Writing AUP3 file...`);
|
console.log(`[muxer-aup3] AUP3 Progress: Writing AUP3 file...`);
|
||||||
|
|
||||||
// Write AUP3 file
|
// Write AUP3 file
|
||||||
fs.writeFileSync(aup3Filename, aup3Content, "utf-8");
|
fs.writeFileSync(aup3Filename, aup3Content, "utf-8");
|
||||||
|
|
||||||
console.log(`[muxer-aup3] AUP3 Progress: Creating clip info file...`);
|
console.log(`[muxer-aup3] AUP3 Progress: Creating clip info file...`);
|
||||||
|
|
||||||
// Create a simple info file with clip details
|
// Create a simple info file with clip details
|
||||||
const infoFilename = aup3Filename.replace('.aup3', '-info.txt');
|
const infoFilename = aup3Filename.replace(".aup3", "-info.txt");
|
||||||
const infoContent = clips.map((clip, index) => {
|
const infoContent = clips
|
||||||
const delayMs = clip.meta.startTime - globalStartTime;
|
.map((clip, index) => {
|
||||||
return `Clip ${index + 1}:
|
const delayMs = clip.meta.startTime - globalStartTime;
|
||||||
|
return `Clip ${index + 1}:
|
||||||
User: ${clip.meta.username} (${clip.meta.userId})
|
User: ${clip.meta.username} (${clip.meta.userId})
|
||||||
Tag: ${clip.meta.tag}
|
Tag: ${clip.meta.tag}
|
||||||
Start Time: ${new Date(clip.meta.startTime).toISOString()}
|
Start Time: ${new Date(clip.meta.startTime).toISOString()}
|
||||||
Delay: ${delayMs}ms
|
Delay: ${delayMs}ms
|
||||||
Duration: ${clip.meta.durationMs}ms
|
Duration: ${clip.meta.durationMs}ms
|
||||||
File: ${path.basename(clip.oggPath)}`;
|
File: ${path.basename(clip.oggPath)}`;
|
||||||
}).join('\n\n');
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
|
||||||
fs.writeFileSync(infoFilename, infoContent, "utf-8");
|
fs.writeFileSync(infoFilename, infoContent, "utf-8");
|
||||||
|
|
||||||
console.log(`[muxer-aup3] AUP3 Progress: 100% - Complete!`);
|
console.log(`[muxer-aup3] AUP3 Progress: 100% - Complete!`);
|
||||||
console.log(`[muxer-aup3] Successfully created AUP3 project!`);
|
console.log(`[muxer-aup3] Successfully created AUP3 project!`);
|
||||||
console.log(`[muxer-aup3] WAV file: ${wavFilename}`);
|
console.log(`[muxer-aup3] WAV file: ${wavFilename}`);
|
||||||
console.log(`[muxer-aup3] AUP3 file: ${aup3Filename}`);
|
console.log(`[muxer-aup3] AUP3 file: ${aup3Filename}`);
|
||||||
console.log(`[muxer-aup3] Clip info saved to: ${infoFilename}`);
|
console.log(`[muxer-aup3] Clip info saved to: ${infoFilename}`);
|
||||||
console.log(`[muxer-aup3] Total clips processed: ${clips.length}`);
|
console.log(`[muxer-aup3] Total clips processed: ${clips.length}`);
|
||||||
console.log(`[muxer-aup3] Duration: ${duration.toFixed(2)} seconds`);
|
console.log(`[muxer-aup3] Duration: ${duration.toFixed(2)} seconds`);
|
||||||
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error(`[muxer-aup3] Error creating AUP3 project:`, error);
|
||||||
console.error(`[muxer-aup3] Error creating AUP3 project:`, error);
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startMuxingToAup3();
|
startMuxingToAup3();
|
||||||
|
|||||||
259
src/muxer.ts
259
src/muxer.ts
@@ -1,153 +1,174 @@
|
|||||||
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import ffmpeg from "fluent-ffmpeg";
|
|
||||||
|
|
||||||
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||||
|
|
||||||
interface EventMetadata {
|
interface EventMetadata {
|
||||||
userId: string;
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
bot?: boolean;
|
bot?: boolean;
|
||||||
roles?: Array<{ id: string; name: string; position: number }>;
|
roles?: Array<{ id: string; name: string; position: number }>;
|
||||||
highestRole?: { id: string; name: string; position: number } | null;
|
highestRole?: { id: string; name: string; position: number } | null;
|
||||||
joinedTimestamp?: number | null;
|
joinedTimestamp?: number | null;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
sessionStartTime?: number;
|
sessionStartTime?: number;
|
||||||
segmentIndex?: number;
|
segmentIndex?: number;
|
||||||
segmentMs?: number;
|
segmentMs?: number;
|
||||||
startTime: number;
|
startTime: number;
|
||||||
endTime: number;
|
endTime: number;
|
||||||
durationMs: number;
|
durationMs: number;
|
||||||
filename: string;
|
filename: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClipInfo {
|
interface ClipInfo {
|
||||||
oggPath: string;
|
oggPath: string;
|
||||||
jsonPath: string;
|
jsonPath: string;
|
||||||
meta: EventMetadata;
|
meta: EventMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startMuxing() {
|
async function startMuxing() {
|
||||||
console.log("[muxer] Scanning recordings directory...");
|
console.log("[muxer] Scanning recordings directory...");
|
||||||
if (!fs.existsSync(recordingsDir)) {
|
if (!fs.existsSync(recordingsDir)) {
|
||||||
console.error("[muxer] Recordings directory not found.");
|
console.error("[muxer] Recordings directory not found.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clips: ClipInfo[] = [];
|
const clips: ClipInfo[] = [];
|
||||||
|
|
||||||
// Scan user directories
|
// Scan user directories
|
||||||
const items = fs.readdirSync(recordingsDir);
|
const items = fs.readdirSync(recordingsDir);
|
||||||
console.log(`[muxer] Found ${items.length} directories to scan...`);
|
console.log(`[muxer] Found ${items.length} directories to scan...`);
|
||||||
|
|
||||||
let processedDirs = 0;
|
let processedDirs = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const itemPath = path.join(recordingsDir, item);
|
const itemPath = path.join(recordingsDir, item);
|
||||||
if (fs.statSync(itemPath).isDirectory()) {
|
if (fs.statSync(itemPath).isDirectory()) {
|
||||||
const files = fs.readdirSync(itemPath);
|
const files = fs.readdirSync(itemPath);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith(".json")) {
|
if (file.endsWith(".json")) {
|
||||||
const jsonPath = path.join(itemPath, file);
|
const jsonPath = path.join(itemPath, file);
|
||||||
const oggPath = jsonPath.replace(/\.json$/, ".ogg");
|
const oggPath = jsonPath.replace(/\.json$/, ".ogg");
|
||||||
if (fs.existsSync(oggPath)) {
|
if (fs.existsSync(oggPath)) {
|
||||||
try {
|
try {
|
||||||
// Check if OGG file is valid (not empty and has reasonable size)
|
// Check if OGG file is valid (not empty and has reasonable size)
|
||||||
const oggStats = fs.statSync(oggPath);
|
const oggStats = fs.statSync(oggPath);
|
||||||
if (oggStats.size === 0) {
|
if (oggStats.size === 0) {
|
||||||
console.warn(`[muxer] Skipping empty OGG file: ${oggPath}`);
|
console.warn(`[muxer] Skipping empty OGG file: ${oggPath}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip files that are too small (less than 1KB likely corrupted)
|
// Skip files that are too small (less than 1KB likely corrupted)
|
||||||
if (oggStats.size < 1024) {
|
if (oggStats.size < 1024) {
|
||||||
console.warn(`[muxer] Skipping too small OGG file (${oggStats.size} bytes): ${oggPath}`);
|
console.warn(
|
||||||
continue;
|
`[muxer] Skipping too small OGG file (${oggStats.size} bytes): ${oggPath}`,
|
||||||
}
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if OGG file has valid header (starts with "OggS")
|
// Check if OGG file has valid header (starts with "OggS")
|
||||||
const oggBuffer = fs.readFileSync(oggPath);
|
const oggBuffer = fs.readFileSync(oggPath);
|
||||||
const oggHeader = oggBuffer.slice(0, 4).toString();
|
const oggHeader = oggBuffer.slice(0, 4).toString();
|
||||||
if (oggHeader !== "OggS") {
|
if (oggHeader !== "OggS") {
|
||||||
console.warn(`[muxer] Skipping invalid OGG file (bad header): ${oggPath}`);
|
console.warn(
|
||||||
continue;
|
`[muxer] Skipping invalid OGG file (bad header): ${oggPath}`,
|
||||||
}
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const meta: EventMetadata = JSON.parse(fs.readFileSync(jsonPath, "utf-8"));
|
const meta: EventMetadata = JSON.parse(
|
||||||
clips.push({ oggPath, jsonPath, meta });
|
fs.readFileSync(jsonPath, "utf-8"),
|
||||||
} catch (e) {
|
);
|
||||||
console.error(`[muxer] Failed to read/parse JSON: ${jsonPath}`, e);
|
clips.push({ oggPath, jsonPath, meta });
|
||||||
}
|
} catch (e) {
|
||||||
}
|
console.error(
|
||||||
}
|
`[muxer] Failed to read/parse JSON: ${jsonPath}`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
processedDirs++;
|
}
|
||||||
const progress = ((processedDirs / items.length) * 100).toFixed(2);
|
|
||||||
console.log(`[muxer] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
processedDirs++;
|
||||||
|
const progress = ((processedDirs / items.length) * 100).toFixed(2);
|
||||||
|
console.log(
|
||||||
|
`[muxer] Scanning progress: ${progress}% (${processedDirs}/${items.length} directories)`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (clips.length === 0) {
|
if (clips.length === 0) {
|
||||||
console.log("[muxer] No recording clips found to mux.");
|
console.log("[muxer] No recording clips found to mux.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by startTime so chronologically they are in order
|
// Sort by startTime so chronologically they are in order
|
||||||
clips.sort((a, b) => a.meta.startTime - b.meta.startTime);
|
clips.sort((a, b) => a.meta.startTime - b.meta.startTime);
|
||||||
|
|
||||||
// Find the global start time
|
// Find the global start time
|
||||||
const globalStartTime = clips[0].meta.startTime;
|
const globalStartTime = clips[0].meta.startTime;
|
||||||
console.log(`[muxer] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`);
|
console.log(
|
||||||
|
`[muxer] Found ${clips.length} clips. Base timestamp: ${globalStartTime}`,
|
||||||
|
);
|
||||||
|
|
||||||
const command = ffmpeg();
|
const command = ffmpeg();
|
||||||
const filterParts: string[] = [];
|
const filterParts: string[] = [];
|
||||||
|
|
||||||
console.log(`[muxer] Creating audio filters for ${clips.length} clips...`);
|
console.log(`[muxer] Creating audio filters for ${clips.length} clips...`);
|
||||||
clips.forEach((clip, index) => {
|
clips.forEach((clip, index) => {
|
||||||
command.input(clip.oggPath);
|
command.input(clip.oggPath);
|
||||||
|
|
||||||
// Calculate delay relative to the global start time
|
// Calculate delay relative to the global start time
|
||||||
const delayMs = clip.meta.startTime - globalStartTime;
|
const delayMs = clip.meta.startTime - globalStartTime;
|
||||||
|
|
||||||
// FFmpeg filter structure: [0:a]adelay=1000|1000[a0]
|
// FFmpeg filter structure: [0:a]adelay=1000|1000[a0]
|
||||||
// Setting adelay multiple times covers stereo channels.
|
// Setting adelay multiple times covers stereo channels.
|
||||||
// We ensure all multiple channels get delayed.
|
// We ensure all multiple channels get delayed.
|
||||||
const inputSpecifier = `[${index}:a]`;
|
const inputSpecifier = `[${index}:a]`;
|
||||||
const outputSpecifier = `[pad${index}]`;
|
const outputSpecifier = `[pad${index}]`;
|
||||||
|
|
||||||
filterParts.push(`${inputSpecifier}adelay=${delayMs}|${delayMs}${outputSpecifier}`);
|
filterParts.push(
|
||||||
|
`${inputSpecifier}adelay=${delayMs}|${delayMs}${outputSpecifier}`,
|
||||||
|
);
|
||||||
|
|
||||||
const progress = (((index + 1) / clips.length) * 100).toFixed(2);
|
const progress = (((index + 1) / clips.length) * 100).toFixed(2);
|
||||||
console.log(`[muxer] Filter creation progress: ${progress}% (${index + 1}/${clips.length} clips)`);
|
console.log(
|
||||||
|
`[muxer] Filter creation progress: ${progress}% (${index + 1}/${clips.length} clips)`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge them using amix
|
||||||
|
const amixInputs = clips.map((_, i) => `[pad${i}]`).join("");
|
||||||
|
// We add the amix command. dropout_transition=0 avoids volume drop when streams end.
|
||||||
|
filterParts.push(
|
||||||
|
`${amixInputs}amix=inputs=${clips.length}:dropout_transition=0[out]`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputFilename = path.join(recordingsDir, `muxed-${Date.now()}.mp3`);
|
||||||
|
|
||||||
|
console.log(`[muxer] Combining clips. This might take a while...`);
|
||||||
|
|
||||||
|
// Using fluent-ffmpeg's complexFilter
|
||||||
|
command
|
||||||
|
.complexFilter(filterParts, "out")
|
||||||
|
.audioCodec("libmp3lame")
|
||||||
|
.save(outputFilename)
|
||||||
|
.on("progress", (progress) => {
|
||||||
|
if (progress.percent) {
|
||||||
|
console.log(`[muxer] Progress: ${progress.percent.toFixed(2)}%`);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on("end", () => {
|
||||||
|
console.log(
|
||||||
|
`[muxer] Successfully muxed! Output saved to: ${outputFilename}`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.on("error", (err) => {
|
||||||
|
console.error(`[muxer] FFmpeg Error:`, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge them using amix
|
|
||||||
const amixInputs = clips.map((_, i) => `[pad${i}]`).join("");
|
|
||||||
// We add the amix command. dropout_transition=0 avoids volume drop when streams end.
|
|
||||||
filterParts.push(`${amixInputs}amix=inputs=${clips.length}:dropout_transition=0[out]`);
|
|
||||||
|
|
||||||
const outputFilename = path.join(recordingsDir, `muxed-${Date.now()}.mp3`);
|
|
||||||
|
|
||||||
console.log(`[muxer] Combining clips. This might take a while...`);
|
|
||||||
|
|
||||||
// Using fluent-ffmpeg's complexFilter
|
|
||||||
command
|
|
||||||
.complexFilter(filterParts, "out")
|
|
||||||
.audioCodec("libmp3lame")
|
|
||||||
.save(outputFilename)
|
|
||||||
.on("progress", (progress) => {
|
|
||||||
if (progress.percent) {
|
|
||||||
console.log(`[muxer] Progress: ${progress.percent.toFixed(2)}%`);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on("end", () => {
|
|
||||||
console.log(`[muxer] Successfully muxed! Output saved to: ${outputFilename}`);
|
|
||||||
})
|
|
||||||
.on("error", (err) => {
|
|
||||||
console.error(`[muxer] FFmpeg Error:`, err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
startMuxing();
|
startMuxing();
|
||||||
|
|||||||
@@ -1,57 +1,56 @@
|
|||||||
import {
|
import {
|
||||||
createAudioPlayer,
|
AudioPlayer,
|
||||||
createAudioResource,
|
AudioPlayerStatus,
|
||||||
AudioPlayerStatus,
|
createAudioPlayer,
|
||||||
VoiceConnection,
|
createAudioResource,
|
||||||
AudioPlayer,
|
StreamType,
|
||||||
StreamType
|
VoiceConnection,
|
||||||
} from "@discordjs/voice";
|
} from "@discordjs/voice";
|
||||||
|
import prism from "prism-media";
|
||||||
import { Readable } from "stream";
|
import { Readable } from "stream";
|
||||||
|
|
||||||
import prism from "prism-media";
|
|
||||||
|
|
||||||
export class DiscordPlayer {
|
export class DiscordPlayer {
|
||||||
private player: AudioPlayer;
|
private player: AudioPlayer;
|
||||||
private connection: VoiceConnection | null = null;
|
private connection: VoiceConnection | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.player = createAudioPlayer();
|
this.player = createAudioPlayer();
|
||||||
|
|
||||||
this.player.on(AudioPlayerStatus.Playing, () => {
|
this.player.on(AudioPlayerStatus.Playing, () => {
|
||||||
console.log("[player] Audio player is now playing!");
|
console.log("[player] Audio player is now playing!");
|
||||||
});
|
});
|
||||||
|
|
||||||
this.player.on("error", error => {
|
this.player.on("error", (error) => {
|
||||||
console.error(`[player] Error: ${error.message}`);
|
console.error(`[player] Error: ${error.message}`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setConnection(connection: VoiceConnection) {
|
public setConnection(connection: VoiceConnection) {
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
this.connection.subscribe(this.player);
|
this.connection.subscribe(this.player);
|
||||||
}
|
}
|
||||||
|
|
||||||
public playStream(stream: Readable) {
|
public playStream(stream: Readable) {
|
||||||
console.log("[player] Starting new audio stream...");
|
console.log("[player] Starting new audio stream...");
|
||||||
|
|
||||||
const resource = createAudioResource(stream, {
|
const resource = createAudioResource(stream, {
|
||||||
inputType: StreamType.OggOpus,
|
inputType: StreamType.OggOpus,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.player.play(resource);
|
this.player.play(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
public pause() {
|
public pause() {
|
||||||
this.player.pause(true);
|
this.player.pause(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public unpause() {
|
public unpause() {
|
||||||
this.player.unpause();
|
this.player.unpause();
|
||||||
}
|
}
|
||||||
|
|
||||||
public stop() {
|
public stop() {
|
||||||
this.player.stop();
|
this.player.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const discordPlayer = new DiscordPlayer();
|
export const discordPlayer = new DiscordPlayer();
|
||||||
|
|||||||
652
src/recorder.ts
652
src/recorder.ts
@@ -1,344 +1,392 @@
|
|||||||
|
import {
|
||||||
|
EndBehaviorType,
|
||||||
|
entersState,
|
||||||
|
getVoiceConnection,
|
||||||
|
joinVoiceChannel,
|
||||||
|
VoiceConnectionStatus,
|
||||||
|
} from "@discordjs/voice";
|
||||||
|
import type { Client, VoiceChannel } from "discord.js-selfbot-v13";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { pipeline } from "stream/promises";
|
|
||||||
import {
|
|
||||||
EndBehaviorType,
|
|
||||||
joinVoiceChannel,
|
|
||||||
VoiceConnectionStatus,
|
|
||||||
entersState,
|
|
||||||
getVoiceConnection,
|
|
||||||
} from "@discordjs/voice";
|
|
||||||
import type { VoiceChannel, Client } from "discord.js-selfbot-v13";
|
|
||||||
import prism from "prism-media";
|
import prism from "prism-media";
|
||||||
|
import { pipeline } from "stream/promises";
|
||||||
import { PacketFilter } from "./packetFilter";
|
|
||||||
import { config } from "./config";
|
import { config } from "./config";
|
||||||
|
import { PacketFilter } from "./packetFilter";
|
||||||
|
|
||||||
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
const recordingsDir = process.env.RECORDINGS_DIR ?? "./recordings";
|
||||||
|
|
||||||
// Pastikan folder recordings ada
|
// Pastikan folder recordings ada
|
||||||
if (!fs.existsSync(recordingsDir)) {
|
if (!fs.existsSync(recordingsDir)) {
|
||||||
fs.mkdirSync(recordingsDir, { recursive: true });
|
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Join ke voice channel dan mulai merekam semua user yang bicara.
|
* Join ke voice channel dan mulai merekam semua user yang bicara.
|
||||||
*/
|
*/
|
||||||
export async function startRecording(client: Client, channel: VoiceChannel): Promise<void> {
|
export async function startRecording(
|
||||||
const connection = joinVoiceChannel({
|
client: Client,
|
||||||
channelId: channel.id,
|
channel: VoiceChannel,
|
||||||
guildId: channel.guild.id,
|
): Promise<void> {
|
||||||
adapterCreator: channel.guild.voiceAdapterCreator as any,
|
const connection = joinVoiceChannel({
|
||||||
selfDeaf: false,
|
channelId: channel.id,
|
||||||
selfMute: false,
|
guildId: channel.guild.id,
|
||||||
debug: true,
|
adapterCreator: channel.guild.voiceAdapterCreator as any,
|
||||||
});
|
selfDeaf: false,
|
||||||
|
selfMute: false,
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log(`[recorder] Joining voice channel: #${channel.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
connection.on("debug", (msg) => {
|
||||||
if (config.verbose) {
|
if (config.verbose) {
|
||||||
console.log(`[recorder] Joining voice channel: #${channel.name}`);
|
console.log(`[voice-debug] ${msg}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on("error", (err) => {
|
||||||
|
console.error(`[voice-error]`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tunggu sampai benar-benar terhubung
|
||||||
|
try {
|
||||||
|
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log("[recorder] Connected to voice channel. Recording started.");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[recorder] Failed to connect:", err);
|
||||||
|
connection.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const receiver = connection.receiver;
|
||||||
|
|
||||||
|
// Dengarkan siapapun yang mulai bicara
|
||||||
|
receiver.speaking.on("start", async (userId) => {
|
||||||
|
// Coba ambil data user dari cache atau fetch dari API
|
||||||
|
const user =
|
||||||
|
client.users.cache.get(userId) ||
|
||||||
|
(await client.users.fetch(userId).catch(() => null));
|
||||||
|
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
|
||||||
|
if ((global as any).updateActiveUser) {
|
||||||
|
(global as any).updateActiveUser(userId, {
|
||||||
|
username,
|
||||||
|
avatar: avatarUrl,
|
||||||
|
speaking: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
connection.on('debug', msg => {
|
// Jangan record kalau sudah ada stream aktif untuk user ini
|
||||||
if (config.verbose) {
|
if (receiver.subscriptions.has(userId)) return;
|
||||||
console.log(`[voice-debug] ${msg}`);
|
|
||||||
}
|
const timestamp = Date.now();
|
||||||
|
const sessionStartTime = timestamp;
|
||||||
|
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);
|
||||||
|
if (!fs.existsSync(userDir)) {
|
||||||
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioStream = receiver.subscribe(userId, {
|
||||||
|
end: {
|
||||||
|
behavior: EndBehaviorType.AfterSilence,
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.on('error', err => {
|
|
||||||
console.error(`[voice-error]`, err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tunggu sampai benar-benar terhubung
|
|
||||||
try {
|
try {
|
||||||
await entersState(connection, VoiceConnectionStatus.Ready, 15_000);
|
// --- OGG file recording with segment rotation ---
|
||||||
if (config.verbose) {
|
const packetFilterForOgg = new PacketFilter(8);
|
||||||
console.log("[recorder] Connected to voice channel. Recording started.");
|
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
||||||
}
|
let segmentIndex = 0;
|
||||||
} catch (err) {
|
let currentSegment: {
|
||||||
console.error("[recorder] Failed to connect:", err);
|
index: number;
|
||||||
connection.destroy();
|
startTime: number;
|
||||||
return;
|
endTime: number | null;
|
||||||
}
|
filename: string;
|
||||||
|
jsonFilename: string;
|
||||||
|
oggStream: any;
|
||||||
|
out: fs.WriteStream;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
const receiver = connection.receiver;
|
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);
|
||||||
|
|
||||||
// Dengarkan siapapun yang mulai bicara
|
const segment = {
|
||||||
receiver.speaking.on("start", async (userId) => {
|
index,
|
||||||
// Coba ambil data user dari cache atau fetch dari API
|
startTime,
|
||||||
const user = client.users.cache.get(userId) || await client.users.fetch(userId).catch(() => null);
|
endTime: null as number | null,
|
||||||
const member = channel.guild.members.cache.get(userId) || await channel.guild.members.fetch(userId).catch(() => null);
|
filename: segmentFilename,
|
||||||
const username = user?.username ?? "Unknown User";
|
jsonFilename: segmentJsonFilename,
|
||||||
const avatarUrl = user?.displayAvatarURL({ format: "png", size: 64 }) ?? "https://cdn.discordapp.com/embed/avatars/0.png";
|
oggStream,
|
||||||
const displayName = member?.displayName ?? username;
|
out,
|
||||||
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]"
|
out.on("finish", () => {
|
||||||
console.log(`${username} [voice activity]`);
|
if (config.verbose) {
|
||||||
|
console.log(`[recorder] Saved: ${segment.filename}`);
|
||||||
|
}
|
||||||
|
const endTime = segment.endTime ?? Date.now();
|
||||||
|
|
||||||
// Notify webserver
|
const eventMetadata = {
|
||||||
if ((global as any).updateActiveUser) {
|
userId,
|
||||||
(global as any).updateActiveUser(userId, { username, avatar: avatarUrl, speaking: true });
|
username,
|
||||||
}
|
tag: user?.tag ?? "Unknown#0000",
|
||||||
|
displayName,
|
||||||
// Jangan record kalau sudah ada stream aktif untuk user ini
|
avatarUrl,
|
||||||
if (receiver.subscriptions.has(userId)) return;
|
bot: user?.bot ?? false,
|
||||||
|
roles,
|
||||||
const timestamp = Date.now();
|
highestRole,
|
||||||
const sessionStartTime = timestamp;
|
joinedTimestamp,
|
||||||
const sessionId = `${userId}-${sessionStartTime}`;
|
sessionId,
|
||||||
const recordingSegmentMsRaw = Number(process.env.RECORDING_SEGMENT_MS ?? 5_000);
|
sessionStartTime,
|
||||||
const recordingSegmentMs = Number.isFinite(recordingSegmentMsRaw) && recordingSegmentMsRaw > 0
|
segmentIndex: segment.index,
|
||||||
? recordingSegmentMsRaw
|
segmentMs: recordingSegmentMs,
|
||||||
: 0;
|
startTime: segment.startTime,
|
||||||
const userDir = path.join(recordingsDir, userId);
|
endTime,
|
||||||
if (!fs.existsSync(userDir)) {
|
durationMs: endTime - segment.startTime,
|
||||||
fs.mkdirSync(userDir, { recursive: true });
|
filename: path.basename(segment.filename),
|
||||||
}
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
const audioStream = receiver.subscribe(userId, {
|
segment.jsonFilename,
|
||||||
end: {
|
JSON.stringify(eventMetadata, null, 2),
|
||||||
behavior: EndBehaviorType.AfterSilence,
|
);
|
||||||
duration: 3000,
|
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 ---
|
||||||
|
// OpusScript can crash on long/invalid streams; avoid taking down the process.
|
||||||
|
const decoderConfig = {
|
||||||
|
frameSize: 960,
|
||||||
|
channels: 2 as const,
|
||||||
|
rate: 48000 as const,
|
||||||
|
};
|
||||||
|
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)
|
||||||
|
const outBuf = Buffer.alloc(pcm.length / 4);
|
||||||
|
for (let i = 0; i < outBuf.length / 2; i++) {
|
||||||
|
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
|
||||||
|
}
|
||||||
|
(global as any).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 {
|
try {
|
||||||
// --- OGG file recording with segment rotation ---
|
const d = new prism.opus.Decoder(decoderConfig);
|
||||||
const packetFilterForOgg = new PacketFilter(8);
|
d.on("data", handlePcm);
|
||||||
const oggPacketStream = audioStream.pipe(packetFilterForOgg);
|
d.on("error", (err) => {
|
||||||
let segmentIndex = 0;
|
console.warn("[recorder] Opus decoder error, cooling down:", err);
|
||||||
let currentSegment: {
|
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
||||||
index: number;
|
destroyDecoder();
|
||||||
startTime: number;
|
});
|
||||||
endTime: number | null;
|
decoderCreatedAt = Date.now();
|
||||||
filename: string;
|
return d;
|
||||||
jsonFilename: string;
|
} catch (err) {
|
||||||
oggStream: any;
|
console.warn(
|
||||||
out: fs.WriteStream;
|
"[recorder] Opus decoder init failed, cooling down:",
|
||||||
} | null = null;
|
err,
|
||||||
|
);
|
||||||
const openSegment = () => {
|
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
||||||
const index = segmentIndex++;
|
return null;
|
||||||
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 ---
|
|
||||||
// OpusScript can crash on long/invalid streams; avoid taking down the process.
|
|
||||||
const decoderConfig = { frameSize: 960, channels: 2, rate: 48000 };
|
|
||||||
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)
|
|
||||||
const outBuf = Buffer.alloc(pcm.length / 4);
|
|
||||||
for (let i = 0; i < outBuf.length / 2; i++) {
|
|
||||||
outBuf.writeInt16LE(pcm.readInt16LE(i * 8), i * 2);
|
|
||||||
}
|
|
||||||
(global as any).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;
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[recorder] Opus decoder init failed, cooling down:", err);
|
|
||||||
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const rotateDecoderIfNeeded = () => {
|
|
||||||
if (!currentDecoder || decoderRotateMs <= 0) return;
|
|
||||||
if (Date.now() - decoderCreatedAt < decoderRotateMs) return;
|
|
||||||
destroyDecoder();
|
|
||||||
currentDecoder = createDecoder();
|
|
||||||
};
|
|
||||||
|
|
||||||
const ensureDecoder = () => {
|
|
||||||
if (!currentDecoder) {
|
|
||||||
currentDecoder = createDecoder();
|
|
||||||
}
|
|
||||||
return currentDecoder;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Feed Opus packets one-by-one
|
|
||||||
let packetCount = 0;
|
|
||||||
audioStream.on('data', (chunk: Buffer) => {
|
|
||||||
packetCount++;
|
|
||||||
if (packetCount <= 5) {
|
|
||||||
console.log(`[recorder] Pkt #${packetCount} from ${userId}: ${chunk.length}b | 0x${chunk.slice(0,4).toString('hex')}`);
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("[recorder] Opus decoder write failed, cooling down:", err);
|
|
||||||
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
|
||||||
destroyDecoder();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
audioStream.on('end', () => {
|
|
||||||
closeSegment();
|
|
||||||
destroyDecoder();
|
|
||||||
if ((global as any).updateActiveUser) {
|
|
||||||
(global as any).updateActiveUser(userId, { username, avatar: avatarUrl, speaking: false });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
audioStream.on('error', (err) => {
|
|
||||||
closeSegment();
|
|
||||||
destroyDecoder();
|
|
||||||
console.error(`[recorder] Audio Stream error ${userId}:`, err.message);
|
|
||||||
});
|
|
||||||
packetFilterForOgg.on('error', (err) => {
|
|
||||||
closeSegment();
|
|
||||||
console.error(`[recorder] PacketFilter(ogg) error ${userId}:`, err.message);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[recorder] Failed to create stream for ${userId}:`, e);
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
// Handle disconnect yang tidak disengaja
|
const rotateDecoderIfNeeded = () => {
|
||||||
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
if (!currentDecoder || decoderRotateMs <= 0) return;
|
||||||
if (config.verbose) {
|
if (Date.now() - decoderCreatedAt < decoderRotateMs) return;
|
||||||
console.warn("[recorder] Disconnected from voice channel. Reconnecting...");
|
destroyDecoder();
|
||||||
|
currentDecoder = createDecoder();
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureDecoder = () => {
|
||||||
|
if (!currentDecoder) {
|
||||||
|
currentDecoder = createDecoder();
|
||||||
}
|
}
|
||||||
|
return currentDecoder;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Feed Opus packets one-by-one
|
||||||
|
let packetCount = 0;
|
||||||
|
audioStream.on("data", (chunk: Buffer) => {
|
||||||
|
packetCount++;
|
||||||
|
if (packetCount <= 5) {
|
||||||
|
console.log(
|
||||||
|
`[recorder] Pkt #${packetCount} from ${userId}: ${chunk.length}b | 0x${chunk.slice(0, 4).toString("hex")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 {
|
try {
|
||||||
await Promise.race([
|
decoder.write(chunk);
|
||||||
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
} catch (err) {
|
||||||
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
console.warn(
|
||||||
]);
|
"[recorder] Opus decoder write failed, cooling down:",
|
||||||
// Berhasil reconnect
|
err,
|
||||||
} catch {
|
);
|
||||||
console.error("[recorder] Could not reconnect. Destroying connection.");
|
decoderDisabledUntil = Date.now() + decoderCooldownMs;
|
||||||
connection.destroy();
|
destroyDecoder();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
audioStream.on("end", () => {
|
||||||
if (config.verbose) {
|
closeSegment();
|
||||||
console.log("[recorder] Voice connection destroyed.");
|
destroyDecoder();
|
||||||
|
if ((global as any).updateActiveUser) {
|
||||||
|
(global as any).updateActiveUser(userId, {
|
||||||
|
username,
|
||||||
|
avatar: avatarUrl,
|
||||||
|
speaking: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
audioStream.on("error", (err) => {
|
||||||
|
closeSegment();
|
||||||
|
destroyDecoder();
|
||||||
|
console.error(`[recorder] Audio Stream error ${userId}:`, err.message);
|
||||||
|
});
|
||||||
|
packetFilterForOgg.on("error", (err) => {
|
||||||
|
closeSegment();
|
||||||
|
console.error(
|
||||||
|
`[recorder] PacketFilter(ogg) error ${userId}:`,
|
||||||
|
err.message,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[recorder] Failed to create stream for ${userId}:`, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle disconnect yang tidak disengaja
|
||||||
|
connection.on(VoiceConnectionStatus.Disconnected, async () => {
|
||||||
|
if (config.verbose) {
|
||||||
|
console.warn(
|
||||||
|
"[recorder] Disconnected from voice channel. Reconnecting...",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
entersState(connection, VoiceConnectionStatus.Signalling, 5_000),
|
||||||
|
entersState(connection, VoiceConnectionStatus.Connecting, 5_000),
|
||||||
|
]);
|
||||||
|
// Berhasil reconnect
|
||||||
|
} catch {
|
||||||
|
console.error("[recorder] Could not reconnect. Destroying connection.");
|
||||||
|
connection.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
||||||
|
if (config.verbose) {
|
||||||
|
console.log("[recorder] Voice connection destroyed.");
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hentikan recording dan disconnect dari voice channel.
|
* Hentikan recording dan disconnect dari voice channel.
|
||||||
*/
|
*/
|
||||||
export function stopRecording(guildId: string): void {
|
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.");
|
console.log("[recorder] Recording stopped and disconnected.");
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn("[recorder] No active connection to stop.");
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[recorder] No active connection to stop.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
331
src/webserver.ts
331
src/webserver.ts
@@ -1,182 +1,215 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import http from "http";
|
import http from "http";
|
||||||
import { WebSocketServer } from "ws";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import prism from "prism-media";
|
import prism from "prism-media";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
import { discordPlayer } from "./player";
|
import { discordPlayer } from "./player";
|
||||||
|
|
||||||
const activeUsers = new Map<string, { username: string, avatar: string, speaking: boolean }>();
|
const activeUsers = new Map<
|
||||||
|
string,
|
||||||
|
{ username: string; avatar: string; speaking: boolean }
|
||||||
|
>();
|
||||||
let wsClients = new Set<any>();
|
let wsClients = new Set<any>();
|
||||||
|
|
||||||
// Upsample 24kHz mono s16le → 48kHz stereo s16le (pure JS)
|
// Upsample 24kHz mono s16le → 48kHz stereo s16le (pure JS)
|
||||||
function upsample(mono24k: Buffer): Buffer {
|
function upsample(mono24k: Buffer): Buffer {
|
||||||
const out = Buffer.alloc(mono24k.length * 4);
|
const out = Buffer.alloc(mono24k.length * 4);
|
||||||
for (let i = 0; i < mono24k.length / 2; i++) {
|
for (let i = 0; i < mono24k.length / 2; i++) {
|
||||||
const s = mono24k.readInt16LE(i * 2);
|
const s = mono24k.readInt16LE(i * 2);
|
||||||
out.writeInt16LE(s, i * 8);
|
out.writeInt16LE(s, i * 8);
|
||||||
out.writeInt16LE(s, i * 8 + 2);
|
out.writeInt16LE(s, i * 8 + 2);
|
||||||
out.writeInt16LE(s, i * 8 + 4);
|
out.writeInt16LE(s, i * 8 + 4);
|
||||||
out.writeInt16LE(s, i * 8 + 6);
|
out.writeInt16LE(s, i * 8 + 6);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate RMS dB level of a PCM s16le buffer
|
// Calculate RMS dB level of a PCM s16le buffer
|
||||||
function rmsDb(pcm: Buffer): number {
|
function rmsDb(pcm: Buffer): number {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
const samples = pcm.length / 2;
|
const samples = pcm.length / 2;
|
||||||
for (let i = 0; i < samples; i++) {
|
for (let i = 0; i < samples; i++) {
|
||||||
const s = pcm.readInt16LE(i * 2) / 32768;
|
const s = pcm.readInt16LE(i * 2) / 32768;
|
||||||
sum += s * s;
|
sum += s * s;
|
||||||
}
|
}
|
||||||
const rms = Math.sqrt(sum / samples);
|
const rms = Math.sqrt(sum / samples);
|
||||||
return 20 * Math.log10(Math.max(rms, 1e-10));
|
return 20 * Math.log10(Math.max(rms, 1e-10));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startWebserver(port: number = 3000) {
|
export function startWebserver(port: number = 3000) {
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
|
|
||||||
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(`[webserver] WebSocket server listening on ws://0.0.0.0:${wsPort}`);
|
console.log(
|
||||||
|
`[webserver] WebSocket server listening on ws://0.0.0.0:${wsPort}`,
|
||||||
|
);
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, "../public")));
|
app.use(express.static(path.join(__dirname, "../public")));
|
||||||
|
|
||||||
// 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;
|
||||||
for (let i = 0; i < userId.length; i++) {
|
for (let i = 0; i < userId.length; i++) {
|
||||||
hash = ((hash << 5) - hash) + userId.charCodeAt(i);
|
hash = (hash << 5) - hash + userId.charCodeAt(i);
|
||||||
hash |= 0;
|
hash |= 0;
|
||||||
}
|
}
|
||||||
const header = Buffer.alloc(4);
|
const header = Buffer.alloc(4);
|
||||||
header.writeInt32LE(hash, 0);
|
header.writeInt32LE(hash, 0);
|
||||||
const packet = Buffer.concat([header, chunk]);
|
const packet = Buffer.concat([header, chunk]);
|
||||||
wsClients.forEach(client => {
|
wsClients.forEach((client) => {
|
||||||
if (client.readyState === 1) client.send(packet);
|
if (client.readyState === 1) client.send(packet);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
(global as any).updateActiveUser = (userId: string, data: { username: string, avatar: string, speaking: boolean }) => {
|
(global as any).updateActiveUser = (
|
||||||
activeUsers.set(userId, data);
|
userId: string,
|
||||||
broadcastUserState();
|
data: { username: string; avatar: string; speaking: boolean },
|
||||||
};
|
) => {
|
||||||
|
activeUsers.set(userId, data);
|
||||||
|
broadcastUserState();
|
||||||
|
};
|
||||||
|
|
||||||
function broadcastUserState() {
|
function broadcastUserState() {
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
type: "user_state",
|
type: "user_state",
|
||||||
users: Array.from(activeUsers.entries()).map(([id, data]) => ({ id, ...data }))
|
users: Array.from(activeUsers.entries()).map(([id, data]) => ({
|
||||||
});
|
id,
|
||||||
wsClients.forEach(client => {
|
...data,
|
||||||
if (client.readyState === 1) client.send(payload);
|
})),
|
||||||
});
|
});
|
||||||
|
wsClients.forEach((client) => {
|
||||||
|
if (client.readyState === 1) client.send(payload);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Outbound: browser PCM (24kHz mono) → Opus → Discord ---
|
||||||
|
const RATE = 48000;
|
||||||
|
const CHANNELS = 2;
|
||||||
|
const FRAME_SIZE = 960;
|
||||||
|
const BYTES_PER_FRAME = FRAME_SIZE * CHANNELS * 2; // 3840 bytes = 20ms
|
||||||
|
const SILENCE_TAIL_MS = 300; // continue sending silence for 300ms after browser stops
|
||||||
|
const MAX_BUF_BYTES = BYTES_PER_FRAME * 50; // cap at 1 second to avoid runaway buffer
|
||||||
|
|
||||||
|
const opusEncoder = new prism.opus.Encoder({
|
||||||
|
rate: RATE,
|
||||||
|
channels: CHANNELS,
|
||||||
|
frameSize: FRAME_SIZE,
|
||||||
|
});
|
||||||
|
const oggBitstream = new prism.opus.OggLogicalBitstream({
|
||||||
|
opusHead: new prism.opus.OpusHead({
|
||||||
|
channelCount: CHANNELS,
|
||||||
|
sampleRate: RATE,
|
||||||
|
}),
|
||||||
|
pageSizeControl: { maxPackets: 1 }, // 1 packet per page = 20ms latency
|
||||||
|
crc: true,
|
||||||
|
});
|
||||||
|
opusEncoder.on("error", () => {});
|
||||||
|
opusEncoder.pipe(oggBitstream);
|
||||||
|
|
||||||
|
// Prime OGG headers before player starts reading
|
||||||
|
opusEncoder.write(Buffer.alloc(BYTES_PER_FRAME, 0));
|
||||||
|
discordPlayer.playStream(oggBitstream);
|
||||||
|
discordPlayer.pause();
|
||||||
|
|
||||||
|
let pcmBuffer = Buffer.alloc(0);
|
||||||
|
let lastBrowserAudioTime = 0;
|
||||||
|
let playerPaused = true;
|
||||||
|
const SILENCE_FRAME = Buffer.alloc(BYTES_PER_FRAME, 0);
|
||||||
|
|
||||||
|
// Log level every 2 seconds
|
||||||
|
let dbAccum = 0,
|
||||||
|
dbCount = 0;
|
||||||
|
setInterval(() => {
|
||||||
|
if (dbCount > 0) {
|
||||||
|
const avg = dbAccum / dbCount;
|
||||||
|
console.log(
|
||||||
|
`[transmit] Audio level: ${avg.toFixed(1)} dBFS (${dbCount} frames/2s)`,
|
||||||
|
);
|
||||||
|
dbAccum = 0;
|
||||||
|
dbCount = 0;
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
// PULL-BASED encode loop: fires every 20ms, pulls exactly one frame from buffer.
|
||||||
|
// This avoids the timing conflict where browser bursts and silence timer collide.
|
||||||
|
setInterval(() => {
|
||||||
|
const msSinceAudio = Date.now() - lastBrowserAudioTime;
|
||||||
|
let frame: Buffer | null = null;
|
||||||
|
|
||||||
|
if (pcmBuffer.length >= BYTES_PER_FRAME) {
|
||||||
|
// Real audio available
|
||||||
|
frame = pcmBuffer.slice(0, BYTES_PER_FRAME);
|
||||||
|
pcmBuffer = pcmBuffer.slice(BYTES_PER_FRAME);
|
||||||
|
|
||||||
|
// Track level for logging
|
||||||
|
dbAccum += rmsDb(frame);
|
||||||
|
dbCount++;
|
||||||
|
|
||||||
|
if (playerPaused) {
|
||||||
|
discordPlayer.unpause();
|
||||||
|
playerPaused = false;
|
||||||
|
console.log("[transmit] Transmitting — Discord indicator ON");
|
||||||
|
}
|
||||||
|
} else if (msSinceAudio < SILENCE_TAIL_MS && msSinceAudio > 0) {
|
||||||
|
// Buffer drained but audio was recent — pad silence to avoid OGG gap
|
||||||
|
frame = SILENCE_FRAME;
|
||||||
|
} else if (!playerPaused && msSinceAudio >= SILENCE_TAIL_MS) {
|
||||||
|
// No audio for a while — pause Discord indicator
|
||||||
|
discordPlayer.pause();
|
||||||
|
playerPaused = true;
|
||||||
|
console.log("[transmit] Stopped — Discord indicator OFF");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return; // already paused, nothing to do
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Outbound: browser PCM (24kHz mono) → Opus → Discord ---
|
// Write one frame. If encoder is backpressured, skip this tick to avoid stalling.
|
||||||
const RATE = 48000;
|
const ok = opusEncoder.write(frame);
|
||||||
const CHANNELS = 2;
|
if (!ok) {
|
||||||
const FRAME_SIZE = 960;
|
opusEncoder.once("drain", () => {}); // re-arm drain without blocking
|
||||||
const BYTES_PER_FRAME = FRAME_SIZE * CHANNELS * 2; // 3840 bytes = 20ms
|
}
|
||||||
const SILENCE_TAIL_MS = 300; // continue sending silence for 300ms after browser stops
|
}, 20);
|
||||||
const MAX_BUF_BYTES = BYTES_PER_FRAME * 50; // cap at 1 second to avoid runaway buffer
|
|
||||||
|
|
||||||
const opusEncoder = new prism.opus.Encoder({ rate: RATE, channels: CHANNELS, frameSize: FRAME_SIZE });
|
wss.on("connection", (ws) => {
|
||||||
const oggBitstream = new prism.opus.OggLogicalBitstream({
|
console.log("[webserver] New WebSocket connection on port " + wsPort);
|
||||||
opusHead: new prism.opus.OpusHead({ channelCount: CHANNELS, sampleRate: RATE }),
|
wsClients.add(ws);
|
||||||
pageSizeControl: { maxPackets: 1 }, // 1 packet per page = 20ms latency
|
|
||||||
crc: true,
|
|
||||||
});
|
|
||||||
opusEncoder.on('error', () => {});
|
|
||||||
opusEncoder.pipe(oggBitstream);
|
|
||||||
|
|
||||||
// Prime OGG headers before player starts reading
|
ws.send(
|
||||||
opusEncoder.write(Buffer.alloc(BYTES_PER_FRAME, 0));
|
JSON.stringify({
|
||||||
discordPlayer.playStream(oggBitstream);
|
type: "user_state",
|
||||||
discordPlayer.pause();
|
users: Array.from(activeUsers.entries()).map(([id, data]) => ({
|
||||||
|
id,
|
||||||
|
...data,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let pcmBuffer = Buffer.alloc(0);
|
ws.on("message", (data: any) => {
|
||||||
let lastBrowserAudioTime = 0;
|
if (!Buffer.isBuffer(data)) return;
|
||||||
let playerPaused = true;
|
lastBrowserAudioTime = Date.now();
|
||||||
const SILENCE_FRAME = Buffer.alloc(BYTES_PER_FRAME, 0);
|
|
||||||
|
|
||||||
// Log level every 2 seconds
|
// Upsample 24kHz mono → 48kHz stereo and add to buffer
|
||||||
let dbAccum = 0, dbCount = 0;
|
const upsampled = upsample(data);
|
||||||
setInterval(() => {
|
|
||||||
if (dbCount > 0) {
|
|
||||||
const avg = dbAccum / dbCount;
|
|
||||||
console.log(`[transmit] Audio level: ${avg.toFixed(1)} dBFS (${dbCount} frames/2s)`);
|
|
||||||
dbAccum = 0; dbCount = 0;
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
// PULL-BASED encode loop: fires every 20ms, pulls exactly one frame from buffer.
|
// Cap buffer to avoid runaway growth during stall
|
||||||
// This avoids the timing conflict where browser bursts and silence timer collide.
|
if (pcmBuffer.length < MAX_BUF_BYTES) {
|
||||||
setInterval(() => {
|
pcmBuffer = Buffer.concat([pcmBuffer, upsampled]);
|
||||||
const msSinceAudio = Date.now() - lastBrowserAudioTime;
|
}
|
||||||
let frame: Buffer | null = null;
|
|
||||||
|
|
||||||
if (pcmBuffer.length >= BYTES_PER_FRAME) {
|
|
||||||
// Real audio available
|
|
||||||
frame = pcmBuffer.slice(0, BYTES_PER_FRAME);
|
|
||||||
pcmBuffer = pcmBuffer.slice(BYTES_PER_FRAME);
|
|
||||||
|
|
||||||
// Track level for logging
|
|
||||||
dbAccum += rmsDb(frame);
|
|
||||||
dbCount++;
|
|
||||||
|
|
||||||
if (playerPaused) {
|
|
||||||
discordPlayer.unpause();
|
|
||||||
playerPaused = false;
|
|
||||||
console.log("[transmit] Transmitting — Discord indicator ON");
|
|
||||||
}
|
|
||||||
} else if (msSinceAudio < SILENCE_TAIL_MS && msSinceAudio > 0) {
|
|
||||||
// Buffer drained but audio was recent — pad silence to avoid OGG gap
|
|
||||||
frame = SILENCE_FRAME;
|
|
||||||
} else if (!playerPaused && msSinceAudio >= SILENCE_TAIL_MS) {
|
|
||||||
// No audio for a while — pause Discord indicator
|
|
||||||
discordPlayer.pause();
|
|
||||||
playerPaused = true;
|
|
||||||
console.log("[transmit] Stopped — Discord indicator OFF");
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
return; // already paused, nothing to do
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write one frame. If encoder is backpressured, skip this tick to avoid stalling.
|
|
||||||
const ok = opusEncoder.write(frame);
|
|
||||||
if (!ok) {
|
|
||||||
opusEncoder.once('drain', () => {}); // re-arm drain without blocking
|
|
||||||
}
|
|
||||||
}, 20);
|
|
||||||
|
|
||||||
wss.on("connection", (ws) => {
|
|
||||||
console.log("[webserver] New WebSocket connection on port " + wsPort);
|
|
||||||
wsClients.add(ws);
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({
|
|
||||||
type: "user_state",
|
|
||||||
users: Array.from(activeUsers.entries()).map(([id, data]) => ({ id, ...data }))
|
|
||||||
}));
|
|
||||||
|
|
||||||
ws.on("message", (data: any) => {
|
|
||||||
if (!Buffer.isBuffer(data)) return;
|
|
||||||
lastBrowserAudioTime = Date.now();
|
|
||||||
|
|
||||||
// Upsample 24kHz mono → 48kHz stereo and add to buffer
|
|
||||||
const upsampled = upsample(data);
|
|
||||||
|
|
||||||
// Cap buffer to avoid runaway growth during stall
|
|
||||||
if (pcmBuffer.length < MAX_BUF_BYTES) {
|
|
||||||
pcmBuffer = Buffer.concat([pcmBuffer, upsampled]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("close", () => { wsClients.delete(ws); });
|
|
||||||
ws.on("error", () => { wsClients.delete(ws); });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(port, "0.0.0.0", () => {
|
ws.on("close", () => {
|
||||||
console.log(`[webserver] Web interface listening on http://0.0.0.0:${port}`);
|
wsClients.delete(ws);
|
||||||
});
|
});
|
||||||
|
ws.on("error", () => {
|
||||||
|
wsClients.delete(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(port, "0.0.0.0", () => {
|
||||||
|
console.log(
|
||||||
|
`[webserver] Web interface listening on http://0.0.0.0:${port}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
5
tests/smoke.test.ts
Normal file
5
tests/smoke.test.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
|
||||||
|
it("runs the test suite", () => {
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
@@ -1,19 +1,14 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src/**/*"],
|
||||||
"src/**/*"
|
"exclude": ["node_modules", "dist"]
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: "node",
|
||||||
include: ['tests/**/*.test.ts']
|
include: ["tests/**/*.test.ts"],
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user