Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 62 additions & 3 deletions packages/engine/src/services/audioVolumeEnvelope.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("node:fs")>();
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;
Expand Down Expand Up @@ -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<typeof import("node:fs")>("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<typeof import("node:fs")>("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
Expand All @@ -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
Expand Down
24 changes: 16 additions & 8 deletions packages/engine/src/services/audioVolumeEnvelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -166,24 +166,32 @@ 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);
if (!layout) return false;

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" });
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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.
}
}
}
}
Loading