From 6ce784471ecd3469eafc70bcc24077f021aa71a8 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 1 Aug 2026 16:05:51 +0700 Subject: [PATCH] fix(gateway): repair Ogg page CRCs + deliver recordings as MP3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/modules/voice-recording/muxer.ts | 2 +- .../src/modules/voice-recording/recorder.ts | 2 +- .../voice-recording/recorder/oggCrc.ts | 56 ++++++++++++++++++ .../recorder/segmentFinalizer.ts | 22 +++++++ .../voice-recording/recorder/uploader.ts | 59 ++++++++++++++++--- .../src/shared/database/voiceRecordingRepo.ts | 4 ++ 6 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 services/discord-gateway/src/modules/voice-recording/recorder/oggCrc.ts diff --git a/services/discord-gateway/src/modules/voice-recording/muxer.ts b/services/discord-gateway/src/modules/voice-recording/muxer.ts index e1c8d45..39983f2 100644 --- a/services/discord-gateway/src/modules/voice-recording/muxer.ts +++ b/services/discord-gateway/src/modules/voice-recording/muxer.ts @@ -142,7 +142,7 @@ async function processJob( inputs: data.inputs, filter: filterComplex, output: data.output, - codec: "libopus", + codec: "libmp3lame", audioFrequency: 48000, audioChannels: 2, }); diff --git a/services/discord-gateway/src/modules/voice-recording/recorder.ts b/services/discord-gateway/src/modules/voice-recording/recorder.ts index a7219ff..ce5a2d3 100644 --- a/services/discord-gateway/src/modules/voice-recording/recorder.ts +++ b/services/discord-gateway/src/modules/voice-recording/recorder.ts @@ -220,7 +220,7 @@ export function stopRecording(guildId: string): void { // Auto-enqueue muxer job if there are multiple segments const segments = snapshot.segments; 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") .then(({ enqueueMuxerJob }) => { enqueueMuxerJob({ diff --git a/services/discord-gateway/src/modules/voice-recording/recorder/oggCrc.ts b/services/discord-gateway/src/modules/voice-recording/recorder/oggCrc.ts new file mode 100644 index 0000000..5c6957d --- /dev/null +++ b/services/discord-gateway/src/modules/voice-recording/recorder/oggCrc.ts @@ -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; +} diff --git a/services/discord-gateway/src/modules/voice-recording/recorder/segmentFinalizer.ts b/services/discord-gateway/src/modules/voice-recording/recorder/segmentFinalizer.ts index 7c892a3..427e3ba 100644 --- a/services/discord-gateway/src/modules/voice-recording/recorder/segmentFinalizer.ts +++ b/services/discord-gateway/src/modules/voice-recording/recorder/segmentFinalizer.ts @@ -6,6 +6,7 @@ import type { UserMetadata, } from "../../message-capture/types.js"; import { createSegmentMetadata } from "./metadata.js"; +import { fixOggCrc } from "./oggCrc.js"; import type { RecordingSession } from "./sessionRecording.js"; import { uploadRecordingSegment } from "./uploader.js"; @@ -54,6 +55,27 @@ export function finalizeSegment(input: SegmentFinalizerInput): void { 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 if (activeSession) { activeSession.registerSegment({ diff --git a/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts b/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts index 5dc8346..6b05b20 100644 --- a/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts +++ b/services/discord-gateway/src/modules/voice-recording/recorder/uploader.ts @@ -1,5 +1,7 @@ import fs from "node:fs"; import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import { createChildLogger } from "@/shared/logger/index"; import { config } from "../../../shared/config/config.js"; import { @@ -12,6 +14,35 @@ import { uploadToTele } from "../../../shared/uploader.js"; import { transcribeRecording } from "../voiceTranscriber.js"; 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 { + 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 @@ -55,19 +86,33 @@ export async function uploadRecordingSegment(input: { 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 - const fileBuffer = await fs.promises.readFile(oggPath); + const fileBuffer = await fs.promises.readFile(mp3Path); const uploadResult = await uploadToTele({ buffer: fileBuffer, - filename: fileName, - contentType: "audio/ogg", + filename: mp3Name, + contentType: "audio/mpeg", uploadUrl: config.TELE_UPLOAD_URL, retries: 3, }); const downloadUrl = uploadResult.url; - // 3. Update DB to uploaded state - await updateVoiceRecordingAsUploaded(id, downloadUrl, Date.now()); + // 2b. Clean up the temporary MP3 (source OGG is kept for transcription) + 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"); // 4. Broadcast via Redis EventBroadcaster (forwarded to WebSocket clients by backend) @@ -82,8 +127,8 @@ export async function uploadRecordingSegment(input: { guild_id: guildId, channel_id: channelId, channel_name: channelName, - filename: fileName, - size_bytes: stats.size, + filename: mp3Name, + size_bytes: mp3Stats.size, download_url: downloadUrl, upload_status: "uploaded", created_at: Date.now(), diff --git a/services/discord-gateway/src/shared/database/voiceRecordingRepo.ts b/services/discord-gateway/src/shared/database/voiceRecordingRepo.ts index 3da5f46..6bf8c2c 100644 --- a/services/discord-gateway/src/shared/database/voiceRecordingRepo.ts +++ b/services/discord-gateway/src/shared/database/voiceRecordingRepo.ts @@ -81,6 +81,8 @@ export async function updateVoiceRecordingAsUploaded( id: string, downloadUrl: string, uploadedAt: number, + filename?: string, + sizeBytes?: number, ): Promise { try { await db() @@ -89,6 +91,8 @@ export async function updateVoiceRecordingAsUploaded( download_url: downloadUrl, upload_status: "uploaded", uploaded_at: uploadedAt, + ...(filename !== undefined ? { filename } : {}), + ...(sizeBytes !== undefined ? { size_bytes: sizeBytes } : {}), }) .where(eq(voiceRecordingsTable.id, id)); } catch (error) {