feat: implement full session recording with muxing support
- Add session recording metadata and mux filter builder in src/recorder/sessionRecording.ts. - Update SegmentMetadata to include recordingSessionId in src/types.ts and src/recorder/metadata.ts. - Modify recorder lifecycle to track sessions, register segments, and finalize recordings on stop. - Create tests for session recording functionality in tests/recorder/sessionRecording.test.ts and tests/recorder/metadata.test.ts. - Document session recording design and implementation plan in docs/superpowers/specs/2026-05-16-session-full-recording-design.md and docs/superpowers/plans/2026-05-16-session-full-recording.md.
This commit is contained in:
673
docs/superpowers/plans/2026-05-16-session-full-recording.md
Normal file
673
docs/superpowers/plans/2026-05-16-session-full-recording.md
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
# Session Full Recording 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:** Build background full-session OGG recording generation from voice join to leave while preserving existing per-user segment recordings.
|
||||||
|
|
||||||
|
**Architecture:** Add a focused session tracker that records session timing, participants, and per-user segment references. Add a session muxer that builds timeline-offset ffmpeg filters and writes `recordings/sessions/<sessionId>/session.json` plus `full.ogg`. Wire recorder lifecycle to create a session on join, register finished human segments, and finalize in the background on stop/destroy.
|
||||||
|
|
||||||
|
**Tech Stack:** TypeScript, Vitest, Node fs/path, ffmpeg via existing `buildMuxFfmpegArgs` and `runFfmpeg`, Discord voice receiver pipeline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Create `src/recorder/sessionRecording.ts`: session metadata types, session tracker, mux filter builder, and session finalization function.
|
||||||
|
- Modify `src/types.ts`: add `recordingSessionId` to per-user `SegmentMetadata`.
|
||||||
|
- Modify `src/recorder/metadata.ts`: accept and write shared `recordingSessionId` into segment metadata.
|
||||||
|
- Modify `src/recorder.ts`: create session on ready, skip bots as now, register segment metadata, finalize session in background on stop/destroy.
|
||||||
|
- Create `tests/recorder/sessionRecording.test.ts`: unit tests for session tracker, mux filter, empty session, and failed mux metadata.
|
||||||
|
- Modify `tests/recorder.test.ts`: assert bot/self users do not register session participants or subscriptions; add stop finalization trigger test with injected session finalizer if needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Session Recording Metadata and Mux Builder
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/recorder/sessionRecording.ts`
|
||||||
|
- Test: `tests/recorder/sessionRecording.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for session tracker and mux filter**
|
||||||
|
|
||||||
|
Create `tests/recorder/sessionRecording.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
buildSessionMuxFilter,
|
||||||
|
createRecordingSession,
|
||||||
|
finalizeRecordingSession,
|
||||||
|
} from "../../src/recorder/sessionRecording";
|
||||||
|
import type { UserMetadata } from "../../src/types";
|
||||||
|
|
||||||
|
function user(overrides: Partial<UserMetadata> = {}): UserMetadata {
|
||||||
|
return {
|
||||||
|
userId: "user-1",
|
||||||
|
username: "Alice",
|
||||||
|
tag: "Alice#0001",
|
||||||
|
displayName: "Alice",
|
||||||
|
avatarUrl: "https://example.com/avatar.png",
|
||||||
|
bot: false,
|
||||||
|
roles: [],
|
||||||
|
highestRole: null,
|
||||||
|
joinedTimestamp: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sessionRecording", () => {
|
||||||
|
it("tracks participants and segment refs", () => {
|
||||||
|
const session = createRecordingSession({
|
||||||
|
guildId: "guild",
|
||||||
|
channelId: "voice",
|
||||||
|
channelName: "Voice",
|
||||||
|
startTime: 1000,
|
||||||
|
recordingsDir: "/recordings",
|
||||||
|
});
|
||||||
|
|
||||||
|
session.registerSegment({
|
||||||
|
user: user(),
|
||||||
|
oggPath: "/recordings/user-1/1500.ogg",
|
||||||
|
jsonPath: "/recordings/user-1/1500.json",
|
||||||
|
startTime: 1500,
|
||||||
|
endTime: 2500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = session.snapshot(3000);
|
||||||
|
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
sessionId: "guild-voice-1000",
|
||||||
|
guildId: "guild",
|
||||||
|
channelId: "voice",
|
||||||
|
channelName: "Voice",
|
||||||
|
startTime: 1000,
|
||||||
|
endTime: 3000,
|
||||||
|
durationMs: 2000,
|
||||||
|
status: "pending",
|
||||||
|
participants: [{ userId: "user-1", username: "Alice" }],
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
userId: "user-1",
|
||||||
|
oggPath: "/recordings/user-1/1500.ogg",
|
||||||
|
jsonPath: "/recordings/user-1/1500.json",
|
||||||
|
startTime: 1500,
|
||||||
|
endTime: 2500,
|
||||||
|
offsetMs: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds timeline-offset ffmpeg filter", () => {
|
||||||
|
const filter = buildSessionMuxFilter([
|
||||||
|
{ startTime: 1000 },
|
||||||
|
{ startTime: 2500 },
|
||||||
|
], 1000);
|
||||||
|
|
||||||
|
expect(filter).toBe(
|
||||||
|
"[0:a]adelay=0|0[pad0];[1:a]adelay=1500|1500[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes empty metadata without running ffmpeg", async () => {
|
||||||
|
const session = createRecordingSession({
|
||||||
|
guildId: "guild",
|
||||||
|
channelId: "voice",
|
||||||
|
channelName: "Voice",
|
||||||
|
startTime: 1000,
|
||||||
|
recordingsDir: "/recordings",
|
||||||
|
});
|
||||||
|
const writeJson = vi.fn();
|
||||||
|
const mkdir = vi.fn();
|
||||||
|
const runFfmpeg = vi.fn();
|
||||||
|
|
||||||
|
await finalizeRecordingSession(session, {
|
||||||
|
endTime: 4000,
|
||||||
|
mkdir,
|
||||||
|
writeJson,
|
||||||
|
runFfmpeg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runFfmpeg).not.toHaveBeenCalled();
|
||||||
|
expect(mkdir).toHaveBeenCalledWith("/recordings/sessions/guild-voice-1000");
|
||||||
|
expect(writeJson).toHaveBeenCalledWith(
|
||||||
|
"/recordings/sessions/guild-voice-1000/session.json",
|
||||||
|
expect.objectContaining({ status: "empty", durationMs: 3000 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `src/recorder/sessionRecording.ts` does not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement session tracker and mux filter**
|
||||||
|
|
||||||
|
Create `src/recorder/sessionRecording.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { buildMuxFfmpegArgs, runFfmpeg as defaultRunFfmpeg } from "../audio/ffmpegProcess";
|
||||||
|
import type { UserMetadata } from "../types";
|
||||||
|
|
||||||
|
export type SessionRecordingStatus = "pending" | "completed" | "failed" | "empty";
|
||||||
|
|
||||||
|
export interface RecordingSessionOptions {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
startTime: number;
|
||||||
|
recordingsDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSegmentInput {
|
||||||
|
user: UserMetadata;
|
||||||
|
oggPath: string;
|
||||||
|
jsonPath: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionParticipant {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
tag: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSegmentRef {
|
||||||
|
userId: string;
|
||||||
|
oggPath: string;
|
||||||
|
jsonPath: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
durationMs: number;
|
||||||
|
offsetMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRecordingMetadata {
|
||||||
|
sessionId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
durationMs: number;
|
||||||
|
status: SessionRecordingStatus;
|
||||||
|
outputFile: string | null;
|
||||||
|
participants: SessionParticipant[];
|
||||||
|
segments: SessionSegmentRef[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingSession {
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly recordingsDir: string;
|
||||||
|
readonly startTime: number;
|
||||||
|
registerSegment(input: SessionSegmentInput): void;
|
||||||
|
snapshot(endTime: number): SessionRecordingMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinalizeRecordingSessionDependencies {
|
||||||
|
endTime?: number;
|
||||||
|
mkdir?: (dir: string) => void;
|
||||||
|
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
|
||||||
|
runFfmpeg?: (args: string[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecordingSession(options: RecordingSessionOptions): RecordingSession {
|
||||||
|
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
|
||||||
|
const participants = new Map<string, SessionParticipant>();
|
||||||
|
const segments: SessionSegmentRef[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
recordingsDir: options.recordingsDir,
|
||||||
|
startTime: options.startTime,
|
||||||
|
|
||||||
|
registerSegment(input: SessionSegmentInput): void {
|
||||||
|
participants.set(input.user.userId, {
|
||||||
|
userId: input.user.userId,
|
||||||
|
username: input.user.username,
|
||||||
|
tag: input.user.tag,
|
||||||
|
displayName: input.user.displayName,
|
||||||
|
avatarUrl: input.user.avatarUrl,
|
||||||
|
});
|
||||||
|
segments.push({
|
||||||
|
userId: input.user.userId,
|
||||||
|
oggPath: input.oggPath,
|
||||||
|
jsonPath: input.jsonPath,
|
||||||
|
startTime: input.startTime,
|
||||||
|
endTime: input.endTime,
|
||||||
|
durationMs: input.endTime - input.startTime,
|
||||||
|
offsetMs: input.startTime - options.startTime,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshot(endTime: number): SessionRecordingMetadata {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
guildId: options.guildId,
|
||||||
|
channelId: options.channelId,
|
||||||
|
channelName: options.channelName,
|
||||||
|
startTime: options.startTime,
|
||||||
|
endTime,
|
||||||
|
durationMs: endTime - options.startTime,
|
||||||
|
status: "pending",
|
||||||
|
outputFile: null,
|
||||||
|
participants: Array.from(participants.values()),
|
||||||
|
segments: [...segments],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSessionMuxFilter(
|
||||||
|
segments: Array<{ startTime: number }>,
|
||||||
|
sessionStartTime: number,
|
||||||
|
): string {
|
||||||
|
const filters = segments.map((segment, index) => {
|
||||||
|
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
|
||||||
|
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
|
||||||
|
});
|
||||||
|
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
|
||||||
|
filters.push(`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`);
|
||||||
|
return filters.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finalizeRecordingSession(
|
||||||
|
session: RecordingSession,
|
||||||
|
dependencies: FinalizeRecordingSessionDependencies = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const endTime = dependencies.endTime ?? Date.now();
|
||||||
|
const sessionDir = path.join(session.recordingsDir, "sessions", session.sessionId);
|
||||||
|
const outputFile = path.join(sessionDir, "full.ogg");
|
||||||
|
const metadataFile = path.join(sessionDir, "session.json");
|
||||||
|
const mkdir = dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
|
||||||
|
const writeJson =
|
||||||
|
dependencies.writeJson ??
|
||||||
|
((file, metadata) => fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
|
||||||
|
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
|
||||||
|
|
||||||
|
mkdir(sessionDir);
|
||||||
|
const metadata = session.snapshot(endTime);
|
||||||
|
|
||||||
|
if (metadata.segments.length === 0) {
|
||||||
|
writeJson(metadataFile, { ...metadata, status: "empty" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runFfmpeg(
|
||||||
|
buildMuxFfmpegArgs({
|
||||||
|
inputs: metadata.segments.map((segment) => segment.oggPath),
|
||||||
|
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
|
||||||
|
output: outputFile,
|
||||||
|
codec: "libopus",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
writeJson(metadataFile, {
|
||||||
|
...metadata,
|
||||||
|
status: "completed",
|
||||||
|
outputFile,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
writeJson(metadataFile, {
|
||||||
|
...metadata,
|
||||||
|
status: "failed",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec vitest run tests/recorder/sessionRecording.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit Task 1**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/recorder/sessionRecording.ts tests/recorder/sessionRecording.test.ts
|
||||||
|
git commit -m "feat: add recording session metadata"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Add Shared Recording Session ID to Segment Metadata
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/types.ts`
|
||||||
|
- Modify: `src/recorder/metadata.ts`
|
||||||
|
- Test: `tests/recorder/metadata.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing metadata test**
|
||||||
|
|
||||||
|
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: "user-1",
|
||||||
|
username: "Alice",
|
||||||
|
tag: "Alice#0001",
|
||||||
|
displayName: "Alice",
|
||||||
|
avatarUrl: "https://example.com/avatar.png",
|
||||||
|
bot: false,
|
||||||
|
roles: [],
|
||||||
|
highestRole: null,
|
||||||
|
joinedTimestamp: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const segment = {
|
||||||
|
index: 0,
|
||||||
|
startTime: 1500,
|
||||||
|
endTime: 2500,
|
||||||
|
filename: "/recordings/user-1/1500.ogg",
|
||||||
|
jsonFilename: "/recordings/user-1/1500.json",
|
||||||
|
} as SegmentState;
|
||||||
|
|
||||||
|
describe("createSegmentMetadata", () => {
|
||||||
|
it("includes shared recording session id", () => {
|
||||||
|
const metadata = createSegmentMetadata(
|
||||||
|
user,
|
||||||
|
segment,
|
||||||
|
"user-1-1500",
|
||||||
|
"guild-voice-1000",
|
||||||
|
1000,
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(metadata).toMatchObject({
|
||||||
|
sessionId: "user-1-1500",
|
||||||
|
recordingSessionId: "guild-voice-1000",
|
||||||
|
sessionStartTime: 1000,
|
||||||
|
startTime: 1500,
|
||||||
|
endTime: 2500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec vitest run tests/recorder/metadata.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `createSegmentMetadata` does not accept `recordingSessionId` yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Update metadata type and function signature**
|
||||||
|
|
||||||
|
Modify `src/types.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface SegmentMetadata extends UserMetadata {
|
||||||
|
sessionId: string;
|
||||||
|
recordingSessionId: string;
|
||||||
|
sessionStartTime: number;
|
||||||
|
segmentIndex: number;
|
||||||
|
segmentMs: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
durationMs: number;
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Modify `src/recorder/metadata.ts` function signature and return object:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function createSegmentMetadata(
|
||||||
|
user: UserMetadata,
|
||||||
|
segment: SegmentState,
|
||||||
|
sessionId: string,
|
||||||
|
recordingSessionId: string,
|
||||||
|
sessionStartTime: number,
|
||||||
|
recordingSegmentMs: number,
|
||||||
|
): SegmentMetadata {
|
||||||
|
const endTime = segment.endTime ?? Date.now();
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
sessionId,
|
||||||
|
recordingSessionId,
|
||||||
|
sessionStartTime,
|
||||||
|
segmentIndex: segment.index,
|
||||||
|
segmentMs: recordingSegmentMs,
|
||||||
|
startTime: segment.startTime,
|
||||||
|
endTime,
|
||||||
|
durationMs: endTime - segment.startTime,
|
||||||
|
filename: path.basename(segment.filename),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update existing call sites**
|
||||||
|
|
||||||
|
In `src/recorder.ts`, update the call to include `recordingSession.sessionId` after the per-user `sessionId` argument:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const metadata = createSegmentMetadata(
|
||||||
|
userMetadata,
|
||||||
|
currentSegment,
|
||||||
|
sessionId,
|
||||||
|
recordingSession.sessionId,
|
||||||
|
sessionStartTime,
|
||||||
|
config.RECORDING_SEGMENT_MS,
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run metadata tests and typecheck**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec vitest run tests/recorder/metadata.test.ts
|
||||||
|
pnpm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit Task 2**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/types.ts src/recorder/metadata.ts src/recorder.ts tests/recorder/metadata.test.ts
|
||||||
|
git commit -m "feat: tag segments with recording session"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Wire Session Tracking into Recorder Lifecycle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/recorder.ts`
|
||||||
|
- Modify: `tests/recorder.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing recorder lifecycle tests**
|
||||||
|
|
||||||
|
Append to `tests/recorder.test.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it("finalizes the active recording session when stopped", async () => {
|
||||||
|
const { startRecording, stopRecording } = await import("../src/recorder");
|
||||||
|
const { getVoiceConnection } = await import("@discordjs/voice");
|
||||||
|
const destroy = vi.fn();
|
||||||
|
vi.mocked(getVoiceConnection).mockReturnValue({ destroy } as never);
|
||||||
|
|
||||||
|
await startRecording({ user: { id: "self-user" } } as never, createChannel() as never);
|
||||||
|
stopRecording("guild");
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(destroy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add a test that emits a non-bot user and asserts `subscribe` is called once, while existing self/bot tests still assert zero subscriptions.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run recorder tests to verify failure if session APIs are missing**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec vitest run tests/recorder.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL until recorder imports and uses session recording APIs.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add active session map and finalize helper**
|
||||||
|
|
||||||
|
Modify `src/recorder.ts` imports:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
createRecordingSession,
|
||||||
|
finalizeRecordingSession,
|
||||||
|
type RecordingSession,
|
||||||
|
} from "./recorder/sessionRecording";
|
||||||
|
```
|
||||||
|
|
||||||
|
Add near `recordingsDir`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const activeRecordingSessions = new Map<string, RecordingSession>();
|
||||||
|
|
||||||
|
function finalizeActiveRecordingSession(guildId: string): void {
|
||||||
|
const session = activeRecordingSessions.get(guildId);
|
||||||
|
if (!session) return;
|
||||||
|
activeRecordingSessions.delete(guildId);
|
||||||
|
finalizeRecordingSession(session).catch((error) => {
|
||||||
|
logger.error({ error }, "Failed to finalize recording session");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After connection reaches ready, create and store the session:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const recordingSession = createRecordingSession({
|
||||||
|
guildId: channel.guild.id,
|
||||||
|
channelId: channel.id,
|
||||||
|
channelName: channel.name,
|
||||||
|
startTime: Date.now(),
|
||||||
|
recordingsDir,
|
||||||
|
});
|
||||||
|
activeRecordingSessions.set(channel.guild.id, recordingSession);
|
||||||
|
```
|
||||||
|
|
||||||
|
In segment finish handler, after writing per-user JSON, register the segment:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
recordingSession.registerSegment({
|
||||||
|
user: userMetadata,
|
||||||
|
oggPath: currentSegment.filename,
|
||||||
|
jsonPath: currentSegment.jsonFilename,
|
||||||
|
startTime: currentSegment.startTime,
|
||||||
|
endTime: metadata.endTime,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
In `stopRecording(guildId)`, call `finalizeActiveRecordingSession(guildId)` before destroying connection.
|
||||||
|
|
||||||
|
In `connection.on(VoiceConnectionStatus.Destroyed, ...)`, call `finalizeActiveRecordingSession(channel.guild.id)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run recorder tests and typecheck**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts
|
||||||
|
pnpm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit Task 3**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/recorder.ts tests/recorder.test.ts
|
||||||
|
git commit -m "feat: finalize recording sessions on disconnect"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Final Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- All changed recorder/session files.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run recorder-focused tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm exec vitest run tests/recorder.test.ts tests/recorder/sessionRecording.test.ts tests/recorder/metadata.test.ts tests/audio/ffmpegProcess.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run full test suite**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run typecheck**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run lint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Check git status**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: only intentional implementation, spec, and plan changes are present.
|
||||||
|
```
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Session Full Recording Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The recorder currently writes per-user OGG segments under `recordings/<userId>/`. Each segment has JSON metadata with user identity, bot flag, segment timing, and filename. The requested addition is a second recording view: one full-session OGG from the time the bot joins a voice channel until it leaves, while preserving the current per-user recording files.
|
||||||
|
|
||||||
|
Bot/self audio is excluded before segment creation, so session-level output should only include human participants.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Track one recording session from successful voice join until disconnect/leave.
|
||||||
|
- Preserve existing per-user OGG segment behavior.
|
||||||
|
- Create a background full-session OGG/Opus mix after the session ends.
|
||||||
|
- Store session metadata with duration, participants, segment references, output status, and full recording path.
|
||||||
|
- Keep muxing failures isolated from voice connection shutdown.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Real-time mixed full-session recording.
|
||||||
|
- Replacing per-user segment recording.
|
||||||
|
- Dashboard UI for session playback in this phase.
|
||||||
|
- Database-backed mux job retries in this phase.
|
||||||
|
|
||||||
|
## Output structure
|
||||||
|
|
||||||
|
A completed session writes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
recordings/
|
||||||
|
sessions/
|
||||||
|
<recordingSessionId>/
|
||||||
|
full.ogg
|
||||||
|
session.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`recordingSessionId` is based on guild ID, channel ID, and session start time: `<guildId>-<channelId>-<sessionStartTime>`.
|
||||||
|
|
||||||
|
`session.json` contains:
|
||||||
|
|
||||||
|
- `sessionId`
|
||||||
|
- `guildId`
|
||||||
|
- `channelId`
|
||||||
|
- `channelName`
|
||||||
|
- `startTime`
|
||||||
|
- `endTime`
|
||||||
|
- `durationMs`
|
||||||
|
- `status`: `completed`, `failed`, or `empty`
|
||||||
|
- `outputFile`: relative path to `full.ogg` when present
|
||||||
|
- `participants`: non-bot users observed in the session
|
||||||
|
- `segments`: per-user segment metadata references with absolute timing
|
||||||
|
- `error`: failure message when muxing fails
|
||||||
|
|
||||||
|
Per-user segment JSON also records the shared `recordingSessionId` so full-session muxing can identify which files belong to the same join/leave session.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
1. `startRecording()` creates a session object after the voice connection reaches ready state.
|
||||||
|
2. Each non-bot speaking user still gets the existing per-user `SegmentManager` flow.
|
||||||
|
3. Each finished segment is registered with the active session using its metadata path, OGG path, user ID, start time, and end time.
|
||||||
|
4. `stopRecording(guildId)` or connection destruction finalizes the active session with `endTime`.
|
||||||
|
5. Finalization starts muxing in the background and does not block disconnect.
|
||||||
|
6. Muxing writes `session.json` with `empty`, `completed`, or `failed` status.
|
||||||
|
|
||||||
|
## Muxing design
|
||||||
|
|
||||||
|
The post-processor reads all registered segment metadata for the session. It builds an ffmpeg `filter_complex` that delays each input by `segment.startTime - session.startTime` milliseconds, mixes all delayed inputs with `amix`, and encodes the result to OGG/Opus.
|
||||||
|
|
||||||
|
For a session with no human segments, muxing skips ffmpeg and writes `session.json` with `status: "empty"` and the full session duration.
|
||||||
|
|
||||||
|
For successful muxing, it writes `full.ogg` and `session.json` with `status: "completed"`.
|
||||||
|
|
||||||
|
For failed muxing, it writes `session.json` with `status: "failed"` and the error message.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
- Failure to write `session.json` is logged and does not crash shutdown.
|
||||||
|
- ffmpeg failure is captured in metadata as `status: "failed"`.
|
||||||
|
- Missing or empty segment files are skipped from the mix and recorded as skipped references if needed.
|
||||||
|
- Background mux errors never reject `stopRecording()`.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit test session metadata creation from join to stop.
|
||||||
|
- Unit test bot/self users do not register participants or segments.
|
||||||
|
- Unit test mux filter generation with timeline offsets.
|
||||||
|
- Unit test empty sessions write `status: "empty"` without calling ffmpeg.
|
||||||
|
- Unit test stop triggers background finalization without awaiting ffmpeg.
|
||||||
@@ -20,6 +20,11 @@ import {
|
|||||||
createSegmentMetadata,
|
createSegmentMetadata,
|
||||||
} from "./recorder/metadata";
|
} from "./recorder/metadata";
|
||||||
import { SegmentManager } from "./recorder/segment";
|
import { SegmentManager } from "./recorder/segment";
|
||||||
|
import {
|
||||||
|
createRecordingSession,
|
||||||
|
finalizeRecordingSession,
|
||||||
|
type RecordingSession,
|
||||||
|
} from "./recorder/sessionRecording";
|
||||||
import { retryWithBackoff } from "./retry";
|
import { retryWithBackoff } from "./retry";
|
||||||
import type { PcmBroadcaster } from "./types";
|
import type { PcmBroadcaster } from "./types";
|
||||||
|
|
||||||
@@ -32,6 +37,21 @@ if (!fs.existsSync(recordingsDir)) {
|
|||||||
fs.mkdirSync(recordingsDir, { recursive: true });
|
fs.mkdirSync(recordingsDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeSessions = new Map<string, RecordingSession>();
|
||||||
|
|
||||||
|
export function resetActiveSessions(): void {
|
||||||
|
activeSessions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeActiveRecordingSession(guildId: string): void {
|
||||||
|
const session = activeSessions.get(guildId);
|
||||||
|
if (!session) return;
|
||||||
|
activeSessions.delete(guildId);
|
||||||
|
finalizeRecordingSession(session).catch((error) => {
|
||||||
|
logger.error({ error }, "Failed to finalize recording session");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Join ke voice channel dan mulai merekam semua user yang bicara.
|
* Join ke voice channel dan mulai merekam semua user yang bicara.
|
||||||
*/
|
*/
|
||||||
@@ -78,6 +98,17 @@ export async function startRecording(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
logger.info("Connected to voice channel. Recording started");
|
logger.info("Connected to voice channel. Recording started");
|
||||||
|
|
||||||
|
// Create recording session after connection is ready
|
||||||
|
const sessionStartTime = Date.now();
|
||||||
|
const session = createRecordingSession({
|
||||||
|
guildId: channel.guild.id,
|
||||||
|
channelId: channel.id,
|
||||||
|
channelName: channel.name,
|
||||||
|
startTime: sessionStartTime,
|
||||||
|
recordingsDir,
|
||||||
|
});
|
||||||
|
activeSessions.set(channel.guild.id, session);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ error: err }, "Failed to connect to voice channel");
|
logger.error({ error: err }, "Failed to connect to voice channel");
|
||||||
connection.destroy();
|
connection.destroy();
|
||||||
@@ -109,9 +140,6 @@ export async function startRecording(
|
|||||||
// Jangan record kalau sudah ada stream aktif untuk user ini
|
// Jangan record kalau sudah ada stream aktif untuk user ini
|
||||||
if (receiver.subscriptions.has(userId)) return;
|
if (receiver.subscriptions.has(userId)) return;
|
||||||
|
|
||||||
const timestamp = Date.now();
|
|
||||||
const sessionStartTime = timestamp;
|
|
||||||
const sessionId = `${userId}-${sessionStartTime}`;
|
|
||||||
const userDir = path.join(recordingsDir, userId);
|
const userDir = path.join(recordingsDir, userId);
|
||||||
if (!fs.existsSync(userDir)) {
|
if (!fs.existsSync(userDir)) {
|
||||||
fs.mkdirSync(userDir, { recursive: true });
|
fs.mkdirSync(userDir, { recursive: true });
|
||||||
@@ -149,16 +177,28 @@ export async function startRecording(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeSession = activeSessions.get(channel.guild.id);
|
||||||
let currentSegment = segmentManager.open(oggPacketStream);
|
let currentSegment = segmentManager.open(oggPacketStream);
|
||||||
currentSegment.out.on("finish", () => {
|
currentSegment.out.on("finish", () => {
|
||||||
if (config.VERBOSE) {
|
if (config.VERBOSE) {
|
||||||
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
logger.info({ filename: currentSegment.filename }, "Segment saved");
|
||||||
}
|
}
|
||||||
|
const endTime = currentSegment.endTime ?? Date.now();
|
||||||
|
if (activeSession) {
|
||||||
|
activeSession.registerSegment({
|
||||||
|
user: userMetadata,
|
||||||
|
oggPath: currentSegment.filename,
|
||||||
|
jsonPath: currentSegment.jsonFilename,
|
||||||
|
startTime: currentSegment.startTime,
|
||||||
|
endTime,
|
||||||
|
});
|
||||||
|
}
|
||||||
const metadata = createSegmentMetadata(
|
const metadata = createSegmentMetadata(
|
||||||
userMetadata,
|
userMetadata,
|
||||||
currentSegment,
|
currentSegment,
|
||||||
sessionId,
|
activeSession?.sessionId ?? `${userId}-0`,
|
||||||
sessionStartTime,
|
activeSession?.sessionId ?? `${channel.guild.id}-${channel.id}-0`,
|
||||||
|
activeSession?.startTime ?? 0,
|
||||||
config.RECORDING_SEGMENT_MS,
|
config.RECORDING_SEGMENT_MS,
|
||||||
);
|
);
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -240,6 +280,7 @@ export async function startRecording(
|
|||||||
});
|
});
|
||||||
|
|
||||||
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
connection.on(VoiceConnectionStatus.Destroyed, () => {
|
||||||
|
finalizeActiveRecordingSession(channel.guild.id);
|
||||||
if (config.VERBOSE) {
|
if (config.VERBOSE) {
|
||||||
logger.info("Voice connection destroyed");
|
logger.info("Voice connection destroyed");
|
||||||
}
|
}
|
||||||
@@ -261,4 +302,6 @@ export function stopRecording(guildId: string): void {
|
|||||||
} else {
|
} else {
|
||||||
logger.warn("No active connection to stop");
|
logger.warn("No active connection to stop");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
finalizeActiveRecordingSession(guildId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function createSegmentMetadata(
|
|||||||
user: UserMetadata,
|
user: UserMetadata,
|
||||||
segment: SegmentState,
|
segment: SegmentState,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
recordingSessionId: string,
|
||||||
sessionStartTime: number,
|
sessionStartTime: number,
|
||||||
recordingSegmentMs: number,
|
recordingSegmentMs: number,
|
||||||
): SegmentMetadata {
|
): SegmentMetadata {
|
||||||
@@ -62,6 +63,7 @@ export function createSegmentMetadata(
|
|||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
sessionId,
|
sessionId,
|
||||||
|
recordingSessionId,
|
||||||
sessionStartTime,
|
sessionStartTime,
|
||||||
segmentIndex: segment.index,
|
segmentIndex: segment.index,
|
||||||
segmentMs: recordingSegmentMs,
|
segmentMs: recordingSegmentMs,
|
||||||
|
|||||||
192
src/recorder/sessionRecording.ts
Normal file
192
src/recorder/sessionRecording.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
buildMuxFfmpegArgs,
|
||||||
|
runFfmpeg as defaultRunFfmpeg,
|
||||||
|
} from "../audio/ffmpegProcess";
|
||||||
|
import type { UserMetadata } from "../types";
|
||||||
|
|
||||||
|
export type SessionRecordingStatus =
|
||||||
|
| "pending"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "empty";
|
||||||
|
|
||||||
|
export interface RecordingSessionOptions {
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
startTime: number;
|
||||||
|
recordingsDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSegmentInput {
|
||||||
|
user: UserMetadata;
|
||||||
|
oggPath: string;
|
||||||
|
jsonPath: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionParticipant {
|
||||||
|
userId: string;
|
||||||
|
username: string;
|
||||||
|
tag: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionSegmentRef {
|
||||||
|
userId: string;
|
||||||
|
oggPath: string;
|
||||||
|
jsonPath: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
durationMs: number;
|
||||||
|
offsetMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionRecordingMetadata {
|
||||||
|
sessionId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
channelName: string;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
durationMs: number;
|
||||||
|
status: SessionRecordingStatus;
|
||||||
|
outputFile: string | null;
|
||||||
|
participants: SessionParticipant[];
|
||||||
|
segments: SessionSegmentRef[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingSession {
|
||||||
|
readonly sessionId: string;
|
||||||
|
readonly recordingsDir: string;
|
||||||
|
readonly startTime: number;
|
||||||
|
registerSegment(input: SessionSegmentInput): void;
|
||||||
|
snapshot(endTime: number): SessionRecordingMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FinalizeRecordingSessionDependencies {
|
||||||
|
endTime?: number;
|
||||||
|
mkdir?: (dir: string) => void;
|
||||||
|
writeJson?: (file: string, metadata: SessionRecordingMetadata) => void;
|
||||||
|
runFfmpeg?: (args: string[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRecordingSession(
|
||||||
|
options: RecordingSessionOptions,
|
||||||
|
): RecordingSession {
|
||||||
|
const sessionId = `${options.guildId}-${options.channelId}-${options.startTime}`;
|
||||||
|
const participants = new Map<string, SessionParticipant>();
|
||||||
|
const segments: SessionSegmentRef[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
recordingsDir: options.recordingsDir,
|
||||||
|
startTime: options.startTime,
|
||||||
|
|
||||||
|
registerSegment(input: SessionSegmentInput): void {
|
||||||
|
participants.set(input.user.userId, {
|
||||||
|
userId: input.user.userId,
|
||||||
|
username: input.user.username,
|
||||||
|
tag: input.user.tag,
|
||||||
|
displayName: input.user.displayName,
|
||||||
|
avatarUrl: input.user.avatarUrl,
|
||||||
|
});
|
||||||
|
segments.push({
|
||||||
|
userId: input.user.userId,
|
||||||
|
oggPath: input.oggPath,
|
||||||
|
jsonPath: input.jsonPath,
|
||||||
|
startTime: input.startTime,
|
||||||
|
endTime: input.endTime,
|
||||||
|
durationMs: input.endTime - input.startTime,
|
||||||
|
offsetMs: input.startTime - options.startTime,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
snapshot(endTime: number): SessionRecordingMetadata {
|
||||||
|
return {
|
||||||
|
sessionId,
|
||||||
|
guildId: options.guildId,
|
||||||
|
channelId: options.channelId,
|
||||||
|
channelName: options.channelName,
|
||||||
|
startTime: options.startTime,
|
||||||
|
endTime,
|
||||||
|
durationMs: endTime - options.startTime,
|
||||||
|
status: "pending",
|
||||||
|
outputFile: null,
|
||||||
|
participants: Array.from(participants.values()),
|
||||||
|
segments: [...segments],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSessionMuxFilter(
|
||||||
|
segments: Array<{ startTime: number }>,
|
||||||
|
sessionStartTime: number,
|
||||||
|
): string {
|
||||||
|
const filters = segments.map((segment, index) => {
|
||||||
|
const delayMs = Math.max(0, segment.startTime - sessionStartTime);
|
||||||
|
return `[${index}:a]adelay=${delayMs}|${delayMs}[pad${index}]`;
|
||||||
|
});
|
||||||
|
const inputs = segments.map((_, index) => `[pad${index}]`).join("");
|
||||||
|
filters.push(
|
||||||
|
`${inputs}amix=inputs=${segments.length}:dropout_transition=0[out]`,
|
||||||
|
);
|
||||||
|
return filters.join(";");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function finalizeRecordingSession(
|
||||||
|
session: RecordingSession,
|
||||||
|
dependencies: FinalizeRecordingSessionDependencies = {},
|
||||||
|
): Promise<void> {
|
||||||
|
const endTime = dependencies.endTime ?? Date.now();
|
||||||
|
const sessionDir = path.join(
|
||||||
|
session.recordingsDir,
|
||||||
|
"sessions",
|
||||||
|
session.sessionId,
|
||||||
|
);
|
||||||
|
const outputFile = path.join(sessionDir, "full.ogg");
|
||||||
|
const metadataFile = path.join(sessionDir, "session.json");
|
||||||
|
const mkdir =
|
||||||
|
dependencies.mkdir ?? ((dir) => fs.mkdirSync(dir, { recursive: true }));
|
||||||
|
const writeJson =
|
||||||
|
dependencies.writeJson ??
|
||||||
|
((file, metadata) =>
|
||||||
|
fs.writeFileSync(file, JSON.stringify(metadata, null, 2)));
|
||||||
|
const runFfmpeg = dependencies.runFfmpeg ?? defaultRunFfmpeg;
|
||||||
|
|
||||||
|
mkdir(sessionDir);
|
||||||
|
const metadata = session.snapshot(endTime);
|
||||||
|
|
||||||
|
if (metadata.segments.length === 0) {
|
||||||
|
writeJson(metadataFile, { ...metadata, status: "empty" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runFfmpeg(
|
||||||
|
buildMuxFfmpegArgs({
|
||||||
|
inputs: metadata.segments.map((segment) => segment.oggPath),
|
||||||
|
filter: buildSessionMuxFilter(metadata.segments, metadata.startTime),
|
||||||
|
output: outputFile,
|
||||||
|
codec: "libopus",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
writeJson(metadataFile, {
|
||||||
|
...metadata,
|
||||||
|
status: "completed",
|
||||||
|
outputFile,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
writeJson(metadataFile, {
|
||||||
|
...metadata,
|
||||||
|
status: "failed",
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ export interface SegmentState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SegmentMetadata extends UserMetadata {
|
export interface SegmentMetadata extends UserMetadata {
|
||||||
|
recordingSessionId: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionStartTime: number;
|
sessionStartTime: number;
|
||||||
segmentIndex: number;
|
segmentIndex: number;
|
||||||
|
|||||||
@@ -1,18 +1,102 @@
|
|||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const speaking = new EventEmitter();
|
// Use vi.hoisted so mocks are available at module evaluation time (when vi.mock hoists)
|
||||||
const subscribe = vi.fn();
|
const mocks = vi.hoisted(() => {
|
||||||
const joinVoiceChannel = vi.fn(() => ({
|
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||||
|
const speaker = {
|
||||||
|
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
|
||||||
|
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
|
||||||
|
return speaker;
|
||||||
|
}),
|
||||||
|
emit: vi.fn((event: string, ...args: unknown[]) => {
|
||||||
|
for (const listener of listeners.get(event) ?? []) listener(...args);
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
removeAllListeners: vi.fn(() => {
|
||||||
|
listeners.clear();
|
||||||
|
return speaker;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
mockSpeaker: speaker,
|
||||||
|
mockSubscribe: vi.fn(() => {
|
||||||
|
const oggPacketStream = {
|
||||||
|
pipe: vi.fn(() => ({ pipe: vi.fn(() => ({ on: vi.fn() })) })),
|
||||||
|
unpipe: vi.fn(),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
pipe: vi.fn(() => oggPacketStream),
|
||||||
|
on: vi.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
mockDestroy: vi.fn(),
|
||||||
|
mockWriteFileSync: vi.fn(),
|
||||||
|
mockMkdirSync: vi.fn(),
|
||||||
|
mockOggPipe: vi.fn(() => ({ pipe: vi.fn(() => ({ on: vi.fn() })) })),
|
||||||
|
mockCreateWriteStream: vi.fn(() => ({
|
||||||
|
on: vi.fn(),
|
||||||
|
})),
|
||||||
|
mockFsExistsSync: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("node:fs", () => ({
|
||||||
|
default: {
|
||||||
|
createWriteStream: mocks.mockCreateWriteStream,
|
||||||
|
existsSync: mocks.mockFsExistsSync,
|
||||||
|
mkdirSync: mocks.mockMkdirSync,
|
||||||
|
writeFileSync: mocks.mockWriteFileSync,
|
||||||
|
},
|
||||||
|
createWriteStream: mocks.mockCreateWriteStream,
|
||||||
|
existsSync: mocks.mockFsExistsSync,
|
||||||
|
mkdirSync: mocks.mockMkdirSync,
|
||||||
|
writeFileSync: mocks.mockWriteFileSync,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("prism-media", () => ({
|
||||||
|
opus: {
|
||||||
|
OggLogicalBitstream: vi.fn(function OggLogicalBitstream() {
|
||||||
|
return {
|
||||||
|
pipe: mocks.mockOggPipe,
|
||||||
|
end: vi.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
OpusHead: vi.fn(function OpusHead() {}),
|
||||||
|
Decoder: vi.fn(function Decoder() {}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@discordjs/voice", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("@discordjs/voice")>(
|
||||||
|
"@discordjs/voice",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
joinVoiceChannel: vi.fn(() => ({
|
||||||
receiver: {
|
receiver: {
|
||||||
speaking,
|
speaking: mocks.mockSpeaker,
|
||||||
subscriptions: new Map(),
|
subscriptions: new Map(),
|
||||||
subscribe,
|
subscribe: mocks.mockSubscribe,
|
||||||
},
|
},
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
destroy: vi.fn(),
|
destroy: mocks.mockDestroy,
|
||||||
|
})),
|
||||||
|
entersState: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getVoiceConnection: vi.fn(() => ({
|
||||||
|
destroy: mocks.mockDestroy,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../src/retry", () => ({
|
||||||
|
retryWithBackoff: vi.fn((fn: () => Promise<unknown>) => fn()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
async function flushMicrotasks(): Promise<void> {
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
function createChannel() {
|
function createChannel() {
|
||||||
return {
|
return {
|
||||||
id: "voice-channel",
|
id: "voice-channel",
|
||||||
@@ -28,40 +112,36 @@ function createChannel() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock("@discordjs/voice", async () => {
|
|
||||||
const actual =
|
|
||||||
await vi.importActual<typeof import("@discordjs/voice")>(
|
|
||||||
"@discordjs/voice",
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
joinVoiceChannel,
|
|
||||||
entersState: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("startRecording", () => {
|
describe("startRecording", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
subscribe.mockClear();
|
mocks.mockSubscribe.mockClear();
|
||||||
speaking.removeAllListeners();
|
mocks.mockSpeaker.removeAllListeners();
|
||||||
|
mocks.mockDestroy.mockClear();
|
||||||
|
mocks.mockWriteFileSync.mockClear();
|
||||||
|
mocks.mockMkdirSync.mockClear();
|
||||||
|
mocks.mockOggPipe.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not subscribe to the bot user's own audio", async () => {
|
it("does not subscribe to the bot user's own audio", async () => {
|
||||||
const { startRecording } = await import("../src/recorder");
|
const { startRecording, resetActiveSessions } = await import(
|
||||||
const client = {
|
"../src/recorder"
|
||||||
user: { id: "bot-user" },
|
);
|
||||||
};
|
resetActiveSessions();
|
||||||
|
const client = { user: { id: "bot-user" } };
|
||||||
const channel = createChannel();
|
const channel = createChannel();
|
||||||
|
|
||||||
await startRecording(client as never, channel as never);
|
await startRecording(client as never, channel as never);
|
||||||
speaking.emit("start", "bot-user");
|
mocks.mockSpeaker.emit("start", "bot-user");
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await flushMicrotasks();
|
||||||
|
|
||||||
expect(subscribe).not.toHaveBeenCalled();
|
expect(mocks.mockSubscribe).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not subscribe to other bot users", async () => {
|
it("does not subscribe to other bot users", async () => {
|
||||||
const { startRecording } = await import("../src/recorder");
|
const { startRecording, resetActiveSessions } = await import(
|
||||||
|
"../src/recorder"
|
||||||
|
);
|
||||||
|
resetActiveSessions();
|
||||||
const client = {
|
const client = {
|
||||||
user: { id: "self-user" },
|
user: { id: "self-user" },
|
||||||
users: {
|
users: {
|
||||||
@@ -82,9 +162,80 @@ describe("startRecording", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await startRecording(client as never, createChannel() as never);
|
await startRecording(client as never, createChannel() as never);
|
||||||
speaking.emit("start", "music-bot");
|
mocks.mockSpeaker.emit("start", "music-bot");
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
await flushMicrotasks();
|
||||||
|
|
||||||
expect(subscribe).not.toHaveBeenCalled();
|
expect(mocks.mockSubscribe).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribes to a non-bot human user", async () => {
|
||||||
|
const { startRecording, resetActiveSessions } = await import(
|
||||||
|
"../src/recorder"
|
||||||
|
);
|
||||||
|
resetActiveSessions();
|
||||||
|
const client = {
|
||||||
|
user: { id: "self-user" },
|
||||||
|
users: {
|
||||||
|
cache: new Map([
|
||||||
|
[
|
||||||
|
"human-user",
|
||||||
|
{
|
||||||
|
id: "human-user",
|
||||||
|
username: "Alice",
|
||||||
|
tag: "Alice#0001",
|
||||||
|
bot: false,
|
||||||
|
displayAvatarURL: vi.fn(() => "https://example.com/avatar.png"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
fetch: vi.fn(async () => null),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await startRecording(client as never, createChannel() as never);
|
||||||
|
mocks.mockSpeaker.emit("start", "human-user");
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(mocks.mockSubscribe).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stopRecording", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.mockSubscribe.mockClear();
|
||||||
|
mocks.mockSpeaker.removeAllListeners();
|
||||||
|
mocks.mockDestroy.mockClear();
|
||||||
|
mocks.mockWriteFileSync.mockClear();
|
||||||
|
mocks.mockMkdirSync.mockClear();
|
||||||
|
mocks.mockOggPipe.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys the voice connection", async () => {
|
||||||
|
const { startRecording, stopRecording, resetActiveSessions } = await import(
|
||||||
|
"../src/recorder"
|
||||||
|
);
|
||||||
|
resetActiveSessions();
|
||||||
|
const client = { user: { id: "self-user" } };
|
||||||
|
|
||||||
|
await startRecording(client as never, createChannel() as never);
|
||||||
|
stopRecording("guild");
|
||||||
|
|
||||||
|
expect(mocks.mockDestroy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finalizes the active recording session", async () => {
|
||||||
|
const { startRecording, stopRecording, resetActiveSessions } = await import(
|
||||||
|
"../src/recorder"
|
||||||
|
);
|
||||||
|
resetActiveSessions();
|
||||||
|
const client = { user: { id: "self-user" } };
|
||||||
|
|
||||||
|
await startRecording(client as never, createChannel() as never);
|
||||||
|
stopRecording("guild");
|
||||||
|
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(mocks.mockMkdirSync).toHaveBeenCalled();
|
||||||
|
expect(mocks.mockWriteFileSync).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
46
tests/recorder/metadata.test.ts
Normal file
46
tests/recorder/metadata.test.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createSegmentMetadata } from "../../src/recorder/metadata";
|
||||||
|
import type { SegmentState, UserMetadata } from "../../src/types";
|
||||||
|
|
||||||
|
describe("createSegmentMetadata", () => {
|
||||||
|
const user: UserMetadata = {
|
||||||
|
userId: "user-1",
|
||||||
|
username: "Alice",
|
||||||
|
tag: "Alice#0001",
|
||||||
|
displayName: "Alice",
|
||||||
|
avatarUrl: "https://example.com/avatar.png",
|
||||||
|
bot: false,
|
||||||
|
roles: [],
|
||||||
|
highestRole: null,
|
||||||
|
joinedTimestamp: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const segment = {
|
||||||
|
index: 0,
|
||||||
|
startTime: 1500,
|
||||||
|
endTime: 2500,
|
||||||
|
filename: "/recordings/user-1/1500.ogg",
|
||||||
|
jsonFilename: "/recordings/user-1/1500.json",
|
||||||
|
oggStream: {} as any,
|
||||||
|
out: {} as any,
|
||||||
|
} as SegmentState;
|
||||||
|
|
||||||
|
it("includes shared recording session id", () => {
|
||||||
|
const metadata = createSegmentMetadata(
|
||||||
|
user,
|
||||||
|
segment,
|
||||||
|
"user-1-1500",
|
||||||
|
"guild-voice-1000",
|
||||||
|
1000,
|
||||||
|
5000,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(metadata).toMatchObject({
|
||||||
|
sessionId: "user-1-1500",
|
||||||
|
recordingSessionId: "guild-voice-1000",
|
||||||
|
sessionStartTime: 1000,
|
||||||
|
startTime: 1500,
|
||||||
|
endTime: 2500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
103
tests/recorder/sessionRecording.test.ts
Normal file
103
tests/recorder/sessionRecording.test.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
buildSessionMuxFilter,
|
||||||
|
createRecordingSession,
|
||||||
|
finalizeRecordingSession,
|
||||||
|
} from "../../src/recorder/sessionRecording";
|
||||||
|
import type { UserMetadata } from "../../src/types";
|
||||||
|
|
||||||
|
function user(overrides: Partial<UserMetadata> = {}): UserMetadata {
|
||||||
|
return {
|
||||||
|
userId: "user-1",
|
||||||
|
username: "Alice",
|
||||||
|
tag: "Alice#0001",
|
||||||
|
displayName: "Alice",
|
||||||
|
avatarUrl: "https://example.com/avatar.png",
|
||||||
|
bot: false,
|
||||||
|
roles: [],
|
||||||
|
highestRole: null,
|
||||||
|
joinedTimestamp: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("sessionRecording", () => {
|
||||||
|
it("tracks participants and segment refs", () => {
|
||||||
|
const session = createRecordingSession({
|
||||||
|
guildId: "guild",
|
||||||
|
channelId: "voice",
|
||||||
|
channelName: "Voice",
|
||||||
|
startTime: 1000,
|
||||||
|
recordingsDir: "/recordings",
|
||||||
|
});
|
||||||
|
|
||||||
|
session.registerSegment({
|
||||||
|
user: user(),
|
||||||
|
oggPath: "/recordings/user-1/1500.ogg",
|
||||||
|
jsonPath: "/recordings/user-1/1500.json",
|
||||||
|
startTime: 1500,
|
||||||
|
endTime: 2500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const snapshot = session.snapshot(3000);
|
||||||
|
|
||||||
|
expect(snapshot).toMatchObject({
|
||||||
|
sessionId: "guild-voice-1000",
|
||||||
|
guildId: "guild",
|
||||||
|
channelId: "voice",
|
||||||
|
channelName: "Voice",
|
||||||
|
startTime: 1000,
|
||||||
|
endTime: 3000,
|
||||||
|
durationMs: 2000,
|
||||||
|
status: "pending",
|
||||||
|
participants: [{ userId: "user-1", username: "Alice" }],
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
userId: "user-1",
|
||||||
|
oggPath: "/recordings/user-1/1500.ogg",
|
||||||
|
jsonPath: "/recordings/user-1/1500.json",
|
||||||
|
startTime: 1500,
|
||||||
|
endTime: 2500,
|
||||||
|
offsetMs: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds timeline-offset ffmpeg filter", () => {
|
||||||
|
const filter = buildSessionMuxFilter(
|
||||||
|
[{ startTime: 1000 }, { startTime: 2500 }],
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
expect(filter).toBe(
|
||||||
|
"[0:a]adelay=0|0[pad0];[1:a]adelay=1500|1500[pad1];[pad0][pad1]amix=inputs=2:dropout_transition=0[out]",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("writes empty metadata without running ffmpeg", async () => {
|
||||||
|
const session = createRecordingSession({
|
||||||
|
guildId: "guild",
|
||||||
|
channelId: "voice",
|
||||||
|
channelName: "Voice",
|
||||||
|
startTime: 1000,
|
||||||
|
recordingsDir: "/recordings",
|
||||||
|
});
|
||||||
|
const writeJson = vi.fn();
|
||||||
|
const mkdir = vi.fn();
|
||||||
|
const runFfmpeg = vi.fn();
|
||||||
|
|
||||||
|
await finalizeRecordingSession(session, {
|
||||||
|
endTime: 4000,
|
||||||
|
mkdir,
|
||||||
|
writeJson,
|
||||||
|
runFfmpeg,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runFfmpeg).not.toHaveBeenCalled();
|
||||||
|
expect(mkdir).toHaveBeenCalledWith("/recordings/sessions/guild-voice-1000");
|
||||||
|
expect(writeJson).toHaveBeenCalledWith(
|
||||||
|
"/recordings/sessions/guild-voice-1000/session.json",
|
||||||
|
expect.objectContaining({ status: "empty", durationMs: 3000 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user