976 lines
25 KiB
Markdown
976 lines
25 KiB
Markdown
|
|
# 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.
|