fix(gateway): repair Ogg page CRCs + deliver recordings as MP3
Build & Deploy (Nix) / build-and-deploy (proxy) (push) Successful in 3m44s
Build & Deploy (Nix) / build-and-deploy (backend) (push) Successful in 4m13s
Build & Deploy (Nix) / build-and-deploy (discord-gateway) (push) Successful in 11m33s

Root cause rekaman gak bisa dibuka: prism-media OggLogicalBitstream
dipanggil dengan crc:false (node-crc dihapus dari dependency tree) —
semua page Ogg punya checksum 0 — player strict (ffmpeg, iOS) tolak
dengan 'CRC mismatch / End of file'. Rekaman 13:11 terbukti CRC-invalid.

Fix:
1. recorder/oggCrc.ts (baru): recompute CRC-32 (RFC3533, poly 0x04c11db7,
   initial 0, MSB-first) tiap page OggS in-place — pure JS tanpa native dep.
2. segmentFinalizer.ts: panggil fixOggCrc sebelum upload/merge.
3. recorder/uploader.ts: transcode segment ke MP3 (libmp3lame 128k 48k
   stereo) sebelum upload tele — universal playback. filename+size DB
   di-update; source OGG tetap untuk transkripsi.
4. muxer.ts + recorder.ts: merged session file juga .mp3.

Verified: ffprobe baca segmen yang tadinya CRC mismatch, MP3 valid.
This commit is contained in:
asepharyana
2026-08-01 16:05:51 +07:00
parent 0ef2b715c4
commit 6ce784471e
6 changed files with 136 additions and 9 deletions
@@ -142,7 +142,7 @@ async function processJob(
inputs: data.inputs, inputs: data.inputs,
filter: filterComplex, filter: filterComplex,
output: data.output, output: data.output,
codec: "libopus", codec: "libmp3lame",
audioFrequency: 48000, audioFrequency: 48000,
audioChannels: 2, audioChannels: 2,
}); });
@@ -220,7 +220,7 @@ export function stopRecording(guildId: string): void {
// Auto-enqueue muxer job if there are multiple segments // Auto-enqueue muxer job if there are multiple segments
const segments = snapshot.segments; const segments = snapshot.segments;
if (segments.length >= 2) { if (segments.length >= 2) {
const outputFile = `${config.RECORDINGS_DIR}/merged/${session.sessionId}.ogg`; const outputFile = `${config.RECORDINGS_DIR}/merged/${session.sessionId}.mp3`;
import("./muxer.js") import("./muxer.js")
.then(({ enqueueMuxerJob }) => { .then(({ enqueueMuxerJob }) => {
enqueueMuxerJob({ enqueueMuxerJob({
@@ -0,0 +1,56 @@
import fs from "node:fs";
/**
* Ogg page CRC-32 (RFC 3533): polynomial 0x04c11db7, init 0, MSB-first
* (no reflection), no final XOR. Table-driven, pure JS — no native deps.
*
* prism-media's OggLogicalBitstream writes pages with `crc: false` (node-crc
* was dropped from the dependency tree), which produces files whose page
* checksums are all zero. Strict players (ffmpeg, iOS) reject those with
* "CRC mismatch! / End of file". This re-computes the checksum of every page
* in place, yielding a spec-compliant Ogg file that any player can open.
*/
const CRC_TABLE = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let c = i << 24;
for (let k = 0; k < 8; k++) {
c = c & 0x80000000 ? (c << 1) ^ 0x04c11db7 : c << 1;
}
CRC_TABLE[i] = c | 0;
}
function oggCrc32(buffer: Buffer): number {
let c = 0;
for (let i = 0; i < buffer.length; i++) {
c = ((c << 8) ^ CRC_TABLE[((c >>> 24) ^ buffer[i]) & 0xff]) | 0;
}
return c >>> 0;
}
/**
* Rewrites the CRC field (bytes 22-25) of every OggS page in `filePath`.
* Returns the number of pages fixed (0 if the file is not a valid Ogg stream).
*/
export function fixOggCrc(filePath: string): number {
const data = fs.readFileSync(filePath);
let pos = 0;
let fixed = 0;
while (pos + 27 <= data.length) {
if (data.toString("latin1", pos, pos + 4) !== "OggS") break;
const nsegs = data[pos + 26];
let bodyLen = 0;
for (let i = 0; i < nsegs; i++) bodyLen += data[pos + 27 + i];
const pageEnd = pos + 27 + nsegs + bodyLen;
if (pageEnd > data.length) break;
// Zero the checksum field, then CRC the whole page (RFC 3533).
data.writeUInt32LE(0, pos + 22);
data.writeUInt32LE(oggCrc32(data.subarray(pos, pageEnd)), pos + 22);
fixed++;
pos = pageEnd;
}
if (fixed > 0) fs.writeFileSync(filePath, data);
return fixed;
}
@@ -6,6 +6,7 @@ import type {
UserMetadata, UserMetadata,
} from "../../message-capture/types.js"; } from "../../message-capture/types.js";
import { createSegmentMetadata } from "./metadata.js"; import { createSegmentMetadata } from "./metadata.js";
import { fixOggCrc } from "./oggCrc.js";
import type { RecordingSession } from "./sessionRecording.js"; import type { RecordingSession } from "./sessionRecording.js";
import { uploadRecordingSegment } from "./uploader.js"; import { uploadRecordingSegment } from "./uploader.js";
@@ -54,6 +55,27 @@ export function finalizeSegment(input: SegmentFinalizerInput): void {
logger.info({ filename: currentSegment.filename }, "Segment saved"); logger.info({ filename: currentSegment.filename }, "Segment saved");
} }
// Fix Ogg page CRCs before anything reads the file (upload/merge/transcode).
// prism-media writes pages with crc:false → checksums are zero → strict
// players (ffmpeg, iOS) reject the file. Re-compute in place, pure JS.
try {
const fixed = fixOggCrc(currentSegment.filename);
if (config.VERBOSE) {
logger.info(
{ filename: currentSegment.filename, pages: fixed },
"Ogg page CRCs fixed",
);
}
} catch (err: unknown) {
logger.error(
{
filename: currentSegment.filename,
error: err instanceof Error ? err.message : String(err),
},
"Failed to fix Ogg CRCs — segment may be unplayable",
);
}
// Register segment with the active recording session // Register segment with the active recording session
if (activeSession) { if (activeSession) {
activeSession.registerSegment({ activeSession.registerSegment({
@@ -1,5 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createChildLogger } from "@/shared/logger/index"; import { createChildLogger } from "@/shared/logger/index";
import { config } from "../../../shared/config/config.js"; import { config } from "../../../shared/config/config.js";
import { import {
@@ -12,6 +14,35 @@ import { uploadToTele } from "../../../shared/uploader.js";
import { transcribeRecording } from "../voiceTranscriber.js"; import { transcribeRecording } from "../voiceTranscriber.js";
const logger = createChildLogger("recording-uploader"); const logger = createChildLogger("recording-uploader");
const execFileAsync = promisify(execFile);
/**
* Transcode an Opus/OGG segment to MP3 so recordings are playable on any
* device (Safari/iPhone cannot play OGG). Uses PATH-resolved ffmpeg from the
* Nix closure. Returns the path to the temporary MP3 file.
*/
async function transcodeToMp3(oggPath: string): Promise<string> {
const mp3Path = oggPath.replace(/\.ogg$/i, ".mp3");
await execFileAsync(
"ffmpeg",
[
"-y",
"-i",
oggPath,
"-codec:a",
"libmp3lame",
"-b:a",
"128k",
"-ar",
"48000",
"-ac",
"2",
mp3Path,
],
{ timeout: 30_000 },
);
return mp3Path;
}
/** /**
* Uploads a recorded segment OGG file to external server and registers in database * Uploads a recorded segment OGG file to external server and registers in database
@@ -55,19 +86,33 @@ export async function uploadRecordingSegment(input: {
created_at: Date.now(), created_at: Date.now(),
}); });
// 1b. Transcode segment to MP3 (universal playback; Safari can't play OGG)
const mp3Path = await transcodeToMp3(oggPath);
const mp3Name = path.basename(mp3Path);
const mp3Stats = await fs.promises.stat(mp3Path);
// 2. Perform async upload with retry logic // 2. Perform async upload with retry logic
const fileBuffer = await fs.promises.readFile(oggPath); const fileBuffer = await fs.promises.readFile(mp3Path);
const uploadResult = await uploadToTele({ const uploadResult = await uploadToTele({
buffer: fileBuffer, buffer: fileBuffer,
filename: fileName, filename: mp3Name,
contentType: "audio/ogg", contentType: "audio/mpeg",
uploadUrl: config.TELE_UPLOAD_URL, uploadUrl: config.TELE_UPLOAD_URL,
retries: 3, retries: 3,
}); });
const downloadUrl = uploadResult.url; const downloadUrl = uploadResult.url;
// 3. Update DB to uploaded state // 2b. Clean up the temporary MP3 (source OGG is kept for transcription)
await updateVoiceRecordingAsUploaded(id, downloadUrl, Date.now()); await fs.promises.unlink(mp3Path).catch(() => {});
// 3. Update DB to uploaded state (filename/size reflect the MP3 artifact)
await updateVoiceRecordingAsUploaded(
id,
downloadUrl,
Date.now(),
mp3Name,
mp3Stats.size,
);
logger.info({ id, downloadUrl }, "Recording segment uploaded successfully"); logger.info({ id, downloadUrl }, "Recording segment uploaded successfully");
// 4. Broadcast via Redis EventBroadcaster (forwarded to WebSocket clients by backend) // 4. Broadcast via Redis EventBroadcaster (forwarded to WebSocket clients by backend)
@@ -82,8 +127,8 @@ export async function uploadRecordingSegment(input: {
guild_id: guildId, guild_id: guildId,
channel_id: channelId, channel_id: channelId,
channel_name: channelName, channel_name: channelName,
filename: fileName, filename: mp3Name,
size_bytes: stats.size, size_bytes: mp3Stats.size,
download_url: downloadUrl, download_url: downloadUrl,
upload_status: "uploaded", upload_status: "uploaded",
created_at: Date.now(), created_at: Date.now(),
@@ -81,6 +81,8 @@ export async function updateVoiceRecordingAsUploaded(
id: string, id: string,
downloadUrl: string, downloadUrl: string,
uploadedAt: number, uploadedAt: number,
filename?: string,
sizeBytes?: number,
): Promise<void> { ): Promise<void> {
try { try {
await db() await db()
@@ -89,6 +91,8 @@ export async function updateVoiceRecordingAsUploaded(
download_url: downloadUrl, download_url: downloadUrl,
upload_status: "uploaded", upload_status: "uploaded",
uploaded_at: uploadedAt, uploaded_at: uploadedAt,
...(filename !== undefined ? { filename } : {}),
...(sizeBytes !== undefined ? { size_bytes: sizeBytes } : {}),
}) })
.where(eq(voiceRecordingsTable.id, id)); .where(eq(voiceRecordingsTable.id, id));
} catch (error) { } catch (error) {