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
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:
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user