From 29cb2c20c6a0e73ec783ff5f35b7dc1ac45c2409 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 13 May 2026 14:30:32 +0700 Subject: [PATCH] docs: add aggressive cleanup design spec --- .../2026-05-13-aggressive-cleanup-design.md | 437 ++++++++++++++++++ 1 file changed, 437 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-aggressive-cleanup-design.md diff --git a/docs/superpowers/specs/2026-05-13-aggressive-cleanup-design.md b/docs/superpowers/specs/2026-05-13-aggressive-cleanup-design.md new file mode 100644 index 0000000..e2a5047 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-aggressive-cleanup-design.md @@ -0,0 +1,437 @@ +# Aggressive Codebase Cleanup Design + +**Date:** 2026-05-13 +**Scope:** Biome setup + modularization + unit tests +**Goal:** Production-ready code with strict typing, testability, and maintainability + +--- + +## Overview + +Transform the codebase from a monolithic, loosely-typed structure into modular, well-tested, and strictly-typed components. This involves: + +1. **Tooling:** Add Biome (linter + formatter) and Vitest (test runner) +2. **Modularization:** Break `recorder.ts` into focused modules (`audioStream`, `decoder`, `segment`, `metadata`) +3. **Typing:** Eliminate all `any` types, use strict interfaces +4. **Testing:** Add unit tests for core logic (decoder rotation, segment management, metadata) +5. **Scripts:** Add `typecheck`, `lint`, `format`, `test` npm scripts + +--- + +## Architecture + +### Current State +- `src/recorder.ts` (345 lines): monolithic, handles audio stream, decoder, segment rotation, metadata +- `src/index.ts`: entry point, minimal error handling +- `src/config.ts`, `src/webserver.ts`, `src/player.ts`, etc.: loosely coupled via globals +- No linting, formatting, or tests + +### Target State + +``` +src/ +├── index.ts # Entry point (unchanged) +├── config.ts # Config + env validation (enhanced) +├── types.ts # Shared types (new) +├── recorder/ +│ ├── index.ts # Main recording orchestrator +│ ├── audioStream.ts # Audio stream subscription & lifecycle +│ ├── decoder.ts # Opus decoder with rotation & error handling +│ ├── segment.ts # Segment lifecycle (open, close, rotate) +│ ├── metadata.ts # Event metadata collection & serialization +│ └── packetFilter.ts # (move from root) +├── webserver.ts # (unchanged) +├── player.ts # (unchanged) +├── mock-crc.ts # (unchanged) +├── muxer.ts # (unchanged) +├── muxer-aup3.ts # (unchanged) +└── packetFilter.ts # (unchanged) + +tests/ +├── recorder/ +│ ├── decoder.test.ts # Decoder rotation, error recovery +│ ├── segment.test.ts # Segment open/close/rotate logic +│ └── metadata.test.ts # Metadata collection & serialization +└── config.test.ts # Env validation +``` + +--- + +## Components + +### 1. **types.ts** (new) +Centralized type definitions for recorder subsystem. + +```typescript +export interface UserMetadata { + userId: string; + username: string; + tag: string; + displayName: string; + avatarUrl: string; + bot: boolean; + roles: Array<{ id: string; name: string; position: number }>; + highestRole: { id: string; name: string; position: number } | null; + joinedTimestamp: number | null; +} + +export interface SegmentMetadata { + userId: string; + username: string; + sessionId: string; + sessionStartTime: number; + segmentIndex: number; + startTime: number; + endTime: number; + durationMs: number; + filename: string; + // ... other fields +} + +export interface DecoderConfig { + frameSize: number; + channels: number; + rate: number; +} + +export interface SegmentState { + index: number; + startTime: number; + endTime: number | null; + filename: string; + jsonFilename: string; + oggStream: any; // prism.opus.OggLogicalBitstream + out: fs.WriteStream; +} +``` + +### 2. **config.ts** (enhanced) +Strict env validation with typed config object. + +```typescript +export interface Config { + verbose: boolean; + recordingsDir: string; + recordingSegmentMs: number; + decoderRotateMs: number; + decoderCooldownMs: number; +} + +export function loadConfig(): Config { + const recordingSegmentMsRaw = Number(process.env.RECORDING_SEGMENT_MS ?? 5_000); + const recordingSegmentMs = Number.isFinite(recordingSegmentMsRaw) && recordingSegmentMsRaw > 0 + ? recordingSegmentMsRaw + : 0; + + return { + verbose: process.env.VERBOSE === 'true', + recordingsDir: process.env.RECORDINGS_DIR ?? './recordings', + recordingSegmentMs, + decoderRotateMs: Number(process.env.DECODER_ROTATE_MS ?? 5_000), + decoderCooldownMs: 30_000, + }; +} + +export const config = loadConfig(); +``` + +### 3. **recorder/decoder.ts** (new) +Isolated decoder lifecycle with rotation and error recovery. + +```typescript +export class OpusDecoder { + private decoder: prism.opus.Decoder | null = null; + private disabledUntil = 0; + private createdAt = 0; + private readonly config: DecoderConfig; + private readonly cooldownMs: number; + private readonly rotateMs: number; + private onData: (pcm: Buffer) => void; + + constructor(config: DecoderConfig, cooldownMs: number, rotateMs: number, onData: (pcm: Buffer) => void) { + this.config = config; + this.cooldownMs = cooldownMs; + this.rotateMs = rotateMs; + this.onData = onData; + } + + create(): prism.opus.Decoder | null { + if (Date.now() < this.disabledUntil) return null; + try { + const d = new prism.opus.Decoder(this.config); + d.on('data', this.onData); + d.on('error', () => this.handleError()); + this.createdAt = Date.now(); + return d; + } catch (err) { + console.warn('[decoder] Init failed, cooling down:', err); + this.disabledUntil = Date.now() + this.cooldownMs; + return null; + } + } + + rotateIfNeeded(): void { + if (!this.decoder || this.rotateMs <= 0) return; + if (Date.now() - this.createdAt < this.rotateMs) return; + this.destroy(); + this.decoder = this.create(); + } + + write(chunk: Buffer): void { + if (!this.decoder) return; + try { + this.decoder.write(chunk); + } catch (err) { + console.warn('[decoder] Write failed, cooling down:', err); + this.handleError(); + } + } + + private handleError(): void { + this.disabledUntil = Date.now() + this.cooldownMs; + this.destroy(); + } + + destroy(): void { + if (!this.decoder) return; + this.decoder.removeAllListeners(); + this.decoder.destroy(); + this.decoder = null; + this.createdAt = 0; + } +} +``` + +### 4. **recorder/segment.ts** (new) +Segment lifecycle management (open, close, rotate). + +```typescript +export class SegmentManager { + private currentSegment: SegmentState | null = null; + private segmentIndex = 0; + private readonly recordingSegmentMs: number; + private readonly userDir: string; + private readonly userId: string; + private readonly sessionId: string; + private readonly sessionStartTime: number; + + constructor(userId: string, userDir: string, sessionId: string, sessionStartTime: number, recordingSegmentMs: number) { + this.userId = userId; + this.userDir = userDir; + this.sessionId = sessionId; + this.sessionStartTime = sessionStartTime; + this.recordingSegmentMs = recordingSegmentMs; + } + + open(oggPacketStream: NodeJS.ReadableStream): SegmentState { + const index = this.segmentIndex++; + const startTime = Date.now(); + const segmentFilename = path.join(this.userDir, `${startTime}.ogg`); + const segmentJsonFilename = path.join(this.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: SegmentState = { + index, + startTime, + endTime: null, + filename: segmentFilename, + jsonFilename: segmentJsonFilename, + oggStream, + out, + }; + + this.currentSegment = segment; + return segment; + } + + close(): void { + if (!this.currentSegment) return; + this.currentSegment.endTime = Date.now(); + this.currentSegment.oggStream.end(); + this.currentSegment = null; + } + + rotateIfNeeded(oggPacketStream: NodeJS.ReadableStream): void { + if (!this.currentSegment || this.recordingSegmentMs <= 0) return; + if (Date.now() - this.currentSegment.startTime < this.recordingSegmentMs) return; + this.close(); + this.open(oggPacketStream); + } + + getCurrent(): SegmentState | null { + return this.currentSegment; + } +} +``` + +### 5. **recorder/metadata.ts** (new) +User and event metadata collection. + +```typescript +export async function collectUserMetadata( + client: Client, + userId: string, + channel: VoiceChannel +): Promise { + 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 })) ?? []) as Array<{ id: string; name: string; position: number }>; + const highestRole = roles.length > 0 ? roles[0] : null; + const joinedTimestamp = member?.joinedTimestamp ?? null; + + return { + userId, + username, + tag: user?.tag ?? 'Unknown#0000', + displayName, + avatarUrl, + bot: user?.bot ?? false, + roles, + highestRole, + joinedTimestamp, + }; +} + +export function createSegmentMetadata( + userMetadata: UserMetadata, + segment: SegmentState, + sessionId: string, + sessionStartTime: number, + recordingSegmentMs: number +): SegmentMetadata { + const endTime = segment.endTime ?? Date.now(); + return { + userId: userMetadata.userId, + username: userMetadata.username, + tag: userMetadata.tag, + displayName: userMetadata.displayName, + avatarUrl: userMetadata.avatarUrl, + bot: userMetadata.bot, + roles: userMetadata.roles, + highestRole: userMetadata.highestRole, + joinedTimestamp: userMetadata.joinedTimestamp, + sessionId, + sessionStartTime, + segmentIndex: segment.index, + segmentMs: recordingSegmentMs, + startTime: segment.startTime, + endTime, + durationMs: endTime - segment.startTime, + filename: path.basename(segment.filename), + }; +} +``` + +### 6. **recorder/audioStream.ts** (new) +Audio stream subscription and packet handling. + +```typescript +export async function subscribeToAudioStream( + receiver: VoiceReceiver, + userId: string, + onPacket: (chunk: Buffer) => void, + onEnd: () => void, + onError: (err: Error) => void +): Promise { + const audioStream = receiver.subscribe(userId, { + end: { + behavior: EndBehaviorType.AfterSilence, + duration: 3000, + }, + }); + + audioStream.on('data', onPacket); + audioStream.on('end', onEnd); + audioStream.on('error', onError); + + return audioStream; +} +``` + +### 7. **recorder/index.ts** (new) +Main orchestrator, replaces current `recorder.ts`. + +Coordinates audio stream, decoder, segment, and metadata. Cleaner, testable logic. + +--- + +## Testing Strategy + +### Unit Tests (Vitest) + +**decoder.test.ts:** +- Decoder creation succeeds with valid config +- Decoder enters cooldown on error +- Decoder rotates after timeout +- Write fails gracefully during cooldown + +**segment.test.ts:** +- Segment opens with correct filename +- Segment closes and sets endTime +- Segment rotates when duration exceeded +- Multiple segments tracked correctly + +**metadata.test.ts:** +- User metadata collected correctly +- Segment metadata serialized to JSON +- Missing user data handled gracefully + +**config.test.ts:** +- Env vars parsed correctly +- Invalid values default safely +- Numeric validation works + +### Integration Tests (manual for now) +- Full recording flow: join → speak → record → disconnect +- Decoder error recovery doesn't crash process +- Segment rotation produces correct files + +--- + +## Implementation Order + +1. **Setup tooling:** Biome + Vitest + npm scripts +2. **Create types.ts** — shared interfaces +3. **Enhance config.ts** — strict validation +4. **Extract decoder.ts** — isolated, testable +5. **Extract segment.ts** — lifecycle management +6. **Extract metadata.ts** — data collection +7. **Extract audioStream.ts** — stream handling +8. **Rewrite recorder/index.ts** — orchestrator +9. **Write unit tests** — all modules +10. **Update index.ts** — use new recorder module +11. **Remove old recorder.ts** +12. **Verify behavior** — manual test + +--- + +## Success Criteria + +- ✅ No `any` types (except necessary prism/discord.js types) +- ✅ All modules < 150 lines +- ✅ Unit tests pass (decoder, segment, metadata, config) +- ✅ Biome lint + format passes +- ✅ Recording behavior identical to before +- ✅ npm scripts: `typecheck`, `lint`, `format`, `test` +- ✅ All files committed with clear messages + +--- + +## Trade-offs + +- **More files:** Easier to understand and test, but more to navigate +- **Setup time:** Biome + Vitest + tests add ~2-3 hours, but pay off in maintainability +- **Behavior:** Identical to current; no feature changes