Files
dc-recorder/docs/superpowers/specs/2026-05-13-aggressive-cleanup-design.md

438 lines
13 KiB
Markdown
Raw Normal View History

# 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<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 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<NodeJS.ReadableStream> {
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