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:
MythEclipse
2026-05-16 17:59:17 +07:00
parent 8b33af8286
commit 2744e7035b
9 changed files with 1338 additions and 40 deletions
+48 -5
View File
@@ -20,6 +20,11 @@ import {
createSegmentMetadata,
} from "./recorder/metadata";
import { SegmentManager } from "./recorder/segment";
import {
createRecordingSession,
finalizeRecordingSession,
type RecordingSession,
} from "./recorder/sessionRecording";
import { retryWithBackoff } from "./retry";
import type { PcmBroadcaster } from "./types";
@@ -32,6 +37,21 @@ if (!fs.existsSync(recordingsDir)) {
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.
*/
@@ -78,6 +98,17 @@ export async function startRecording(
},
);
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) {
logger.error({ error: err }, "Failed to connect to voice channel");
connection.destroy();
@@ -109,9 +140,6 @@ export async function startRecording(
// Jangan record kalau sudah ada stream aktif untuk user ini
if (receiver.subscriptions.has(userId)) return;
const timestamp = Date.now();
const sessionStartTime = timestamp;
const sessionId = `${userId}-${sessionStartTime}`;
const userDir = path.join(recordingsDir, userId);
if (!fs.existsSync(userDir)) {
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);
currentSegment.out.on("finish", () => {
if (config.VERBOSE) {
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(
userMetadata,
currentSegment,
sessionId,
sessionStartTime,
activeSession?.sessionId ?? `${userId}-0`,
activeSession?.sessionId ?? `${channel.guild.id}-${channel.id}-0`,
activeSession?.startTime ?? 0,
config.RECORDING_SEGMENT_MS,
);
fs.writeFileSync(
@@ -240,6 +280,7 @@ export async function startRecording(
});
connection.on(VoiceConnectionStatus.Destroyed, () => {
finalizeActiveRecordingSession(channel.guild.id);
if (config.VERBOSE) {
logger.info("Voice connection destroyed");
}
@@ -261,4 +302,6 @@ export function stopRecording(guildId: string): void {
} else {
logger.warn("No active connection to stop");
}
finalizeActiveRecordingSession(guildId);
}
+2
View File
@@ -55,6 +55,7 @@ export function createSegmentMetadata(
user: UserMetadata,
segment: SegmentState,
sessionId: string,
recordingSessionId: string,
sessionStartTime: number,
recordingSegmentMs: number,
): SegmentMetadata {
@@ -62,6 +63,7 @@ export function createSegmentMetadata(
return {
...user,
sessionId,
recordingSessionId,
sessionStartTime,
segmentIndex: segment.index,
segmentMs: recordingSegmentMs,
+192
View 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),
});
}
}
+1
View File
@@ -30,6 +30,7 @@ export interface SegmentState {
}
export interface SegmentMetadata extends UserMetadata {
recordingSessionId: string;
sessionId: string;
sessionStartTime: number;
segmentIndex: number;