diff --git a/packages/engine/src/services/audioVolumeEnvelope.test.ts b/packages/engine/src/services/audioVolumeEnvelope.test.ts index 5d453181a5..970e260b4e 100644 --- a/packages/engine/src/services/audioVolumeEnvelope.test.ts +++ b/packages/engine/src/services/audioVolumeEnvelope.test.ts @@ -1,11 +1,22 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import * as fs from "fs"; import { tmpdir } from "node:os"; import { getFfmpegBinary } from "../utils/ffmpegBinaries.js"; import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js"; +vi.mock("fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + writeFileSync: vi.fn(actual.writeFileSync), + renameSync: vi.fn(actual.renameSync), + rmSync: vi.fn(actual.rmSync), + }; +}); + const SAMPLE_RATE = 48000; const CHANNELS = 2; const HAS_FFMPEG = spawnSync(getFfmpegBinary(), ["-version"], { encoding: "utf-8" }).status === 0; @@ -48,6 +59,53 @@ describe("applyVolumeEnvelopeToWav", () => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); }); + it.each(["write", "rename"])( + "preserves the WAV and removes staging after a %s failure", + async (stage) => { + const dir = tmp(); + const path = join(dir, "failure.wav"); + writeConstantWav(path, 16, 10000); + const original = readFileSync(path); + const actual = await vi.importActual("fs"); + if (stage === "write") { + vi.mocked(fs.writeFileSync).mockImplementationOnce((...args) => { + // Simulate a write that leaves a partial staging file before failing. + Reflect.apply(actual.writeFileSync, actual, args); + throw new Error("injected write failure"); + }); + } else { + vi.mocked(fs.renameSync).mockImplementationOnce(() => { + throw new Error("injected rename failure"); + }); + } + expect(applyVolumeEnvelopeToWav(path, [{ time: 0, volume: 0 }], 0, 0)).toBe(false); + expect(readFileSync(path)).toEqual(original); + expect(readdirSync(dir)).toEqual(["failure.wav"]); + }, + ); + + it("uses a private sibling directory and preserves success if cleanup fails", async () => { + const dir = tmp(); + const path = join(dir, "private.wav"); + writeConstantWav(path, 16, 10000); + const actual = await vi.importActual("fs"); + let stagingDir = ""; + vi.mocked(fs.writeFileSync).mockImplementationOnce((...args) => { + stagingDir = dirname(String(args[0])); + expect(dirname(stagingDir)).toBe(dir); + expect(stagingDir).not.toBe(dir); + if (process.platform !== "win32") + expect(actual.statSync(stagingDir).mode & 0o777).toBe(0o700); + Reflect.apply(actual.writeFileSync, actual, args); + }); + vi.mocked(fs.rmSync).mockImplementationOnce(() => { + throw new Error("injected cleanup failure"); + }); + expect(applyVolumeEnvelopeToWav(path, [{ time: 0, volume: 0 }], 0, 0)).toBe(true); + expect(sampleAt(path, 0)).toBe(0); + expect(readdirSync(stagingDir)).toEqual([]); + }); + it("applies a linear fade sample-accurately", () => { const path = join(tmp(), "a.wav"); const frames = SAMPLE_RATE; // 1 second @@ -64,6 +122,7 @@ describe("applyVolumeEnvelopeToWav", () => { 0, ); expect(applied).toBe(true); + expect(readdirSync(dirname(path))).toEqual(["a.wav"]); expect(sampleAt(path, 0)).toBe(0); // gain 0 expect(sampleAt(path, frames / 2)).toBeCloseTo(5000, -2); // gain ~0.5 diff --git a/packages/engine/src/services/audioVolumeEnvelope.ts b/packages/engine/src/services/audioVolumeEnvelope.ts index 47064bc956..5fc83306e4 100644 --- a/packages/engine/src/services/audioVolumeEnvelope.ts +++ b/packages/engine/src/services/audioVolumeEnvelope.ts @@ -15,8 +15,8 @@ * the caller can fall back to the expression path rather than corrupting audio. */ -import { readFileSync, renameSync, writeFileSync } from "fs"; -import { randomBytes } from "crypto"; +import { mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs"; +import { dirname, join } from "path"; import type { AudioVolumeKeyframe } from "./audioMixer.types.js"; import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope"; import { riffChunks } from "./wavChunks.js"; @@ -166,6 +166,7 @@ export function applyVolumeEnvelopeToWav( const gainAt = createEnvelopeWalker(keyframes, trackStart, baseVolume); if (!gainAt) return false; + let stagingDir: string | undefined; try { const buffer = readFileSync(wavPath); const layout = parseWavLayout(buffer); @@ -173,17 +174,24 @@ export function applyVolumeEnvelopeToWav( scaleSamples(buffer, layout, gainAt); - // Write to a uniquely-named sibling then atomically rename over the - // original. The random name avoids following a pre-planted symlink at a - // predictable path, and the rename means a crash mid-write can't leave a - // truncated WAV for the downstream mix. - const tempPath = `${wavPath}.${randomBytes(6).toString("hex")}.tmp`; - writeFileSync(tempPath, buffer); + // A private sibling directory owns the staging file; the same-filesystem + // rename keeps a failed write from truncating the original WAV. + stagingDir = mkdtempSync(join(dirname(wavPath), ".hf-volume-")); + const tempPath = join(stagingDir, "audio.wav"); + writeFileSync(tempPath, buffer, { flag: "wx" }); renameSync(tempPath, wavPath); return true; } catch { // Any read/parse/write failure → leave the file untouched and let the // caller fall back to the ffmpeg expression path rather than losing audio. return false; + } finally { + if (stagingDir) { + try { + rmSync(stagingDir, { recursive: true, force: true }); + } catch { + // Cleanup must not turn a successfully baked envelope into a fallback. + } + } } }