docs: add aggressive cleanup design spec
This commit is contained in:
437
docs/superpowers/specs/2026-05-13-aggressive-cleanup-design.md
Normal file
437
docs/superpowers/specs/2026-05-13-aggressive-cleanup-design.md
Normal file
@@ -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<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
|
||||
Reference in New Issue
Block a user