/** * FLAC -> MP3 V0 joint-stereo conversion via ffmpeg, with source-hash dedup. * * The SHA-256 of the source FLAC is stamped into a TXXX:MAPLE_SRC_SHA256 ID3 * frame on the produced MP3. Reruns skip conversion when the existing MP3 * carries the matching hash, so the cache is content-addressed against inputs. */ import { mkdir, access } from "node:fs/promises"; import { dirname } from "node:path"; import { parseFile } from "music-metadata"; import NodeID3 from "node-id3"; const HASH_FRAME_DESC = "MAPLE_SRC_SHA256"; export async function fileSha256(path: string): Promise { const hasher = new Bun.CryptoHasher("sha256"); const stream = Bun.file(path).stream(); for await (const chunk of stream) hasher.update(chunk); return hasher.digest("hex"); } async function exists(p: string): Promise { try { await access(p); return true; } catch { return false; } } function readSourceHash(mp3: string): string | null { const tags = NodeID3.read(mp3); const frames = tags.userDefinedText; if (!frames) return null; for (const f of frames) { if (f.description === HASH_FRAME_DESC && f.value) return f.value; } return null; } function writeSourceHash(mp3: string, sha: string): void { NodeID3.update({ userDefinedText: [{ description: HASH_FRAME_DESC, value: sha }] }, mp3); } async function which(cmd: string): Promise { const p = Bun.spawn({ cmd: ["which", cmd], stdout: "pipe", stderr: "pipe" }); await p.exited; return p.exitCode === 0; } export async function convertFlacToMp3(flac: string, mp3: string): Promise { const srcHash = await fileSha256(flac); if (await exists(mp3)) { try { if (readSourceHash(mp3) === srcHash) return false; } catch {} } if (!(await which("ffmpeg"))) throw new Error("ffmpeg not found on PATH"); await mkdir(dirname(mp3), { recursive: true }); const proc = Bun.spawn({ cmd: [ "ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", flac, "-codec:a", "libmp3lame", "-q:a", "0", "-joint_stereo", "1", "-map_metadata", "0", mp3, ], stdout: "inherit", stderr: "inherit", }); await proc.exited; if (proc.exitCode !== 0) throw new Error(`ffmpeg failed for ${flac}`); writeSourceHash(mp3, srcHash); return true; } async function duration(path: string): Promise { try { const md = await parseFile(path, { duration: true }); return md.format.duration ?? 0; } catch { return 0; } } export async function verifyDuration(src: string, dst: string, tolerance = 0.1): Promise { const [a, b] = await Promise.all([duration(src), duration(dst)]); if (Math.abs(a - b) > tolerance) { throw new Error(`duration mismatch: ${src} = ${a.toFixed(3)}s vs ${dst} = ${b.toFixed(3)}s (Δ ${Math.abs(a - b).toFixed(3)}s)`); } }