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
6 changes: 4 additions & 2 deletions packages/engine/src/services/audioFxRender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { enabledAudioFxNodes, type HfAudioFxChain } from "@hyperframes/core/audi
import { serializeAutomation, type HfAutomation } from "@hyperframes/core/audio-automation";
import { acquireBrowser } from "./browserManager.js";
import { createEnvelopeWalker } from "./audioVolumeEnvelope.js";
import { riffChunks } from "./wavChunks.js";
import { riffChunks, wavFormatTag } from "./wavChunks.js";
import type { AudioVolumeKeyframe } from "./audioMixer.types.js";

export class AudioFxRenderError extends Error {
Expand Down Expand Up @@ -57,7 +57,9 @@ function readWavChunks(buf: Buffer): {
let data: Buffer | undefined;
for (const { id, body, size } of riffChunks(buf)) {
if (id === "fmt ") {
head.format = buf.readUInt16LE(body);
const format = wavFormatTag(buf, body, size);
if (format === null) throw new AudioFxRenderError("Invalid or unsupported WAV format header");
head.format = format;
head.channels = buf.readUInt16LE(body + 2);
head.sampleRate = buf.readUInt32LE(body + 4);
head.bits = buf.readUInt16LE(body + 14);
Expand Down
50 changes: 36 additions & 14 deletions packages/engine/src/services/audioVolumeEnvelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
import * as fs from "fs";
import { tmpdir } from "node:os";
import { getFfmpegBinary } from "../utils/ffmpegBinaries.js";
import { readWav } from "./audioFxRender.js";
import { applyVolumeEnvelopeToWav } from "./audioVolumeEnvelope.js";

vi.mock("fs", async (importOriginal) => {
Expand Down Expand Up @@ -307,14 +308,7 @@ describe("applyVolumeEnvelopeToWav", () => {
expect(floatSampleAt(path, SAMPLE_RATE - 1)).toBeCloseTo(0, 3);
});

/**
* The fixtures above are hand-built canonical 44-byte headers, which is NOT
* what the group sub-mix actually hands this function: ffmpeg's `pcm_f32le`
* writes an 18-byte `fmt ` chunk plus a `fact` chunk, putting `data` at
* offset 92. Every assertion above would still pass if this function could
* not read a real one — and an unreadable file returns false, which the
* caller reads as "no automation here" and drops the group's envelope.
*/
// Exercise the installed encoder as well as the captured extensible fixtures.
it.skipIf(!HAS_FFMPEG)("reads what ffmpeg actually writes, not just a canonical header", () => {
const path = join(tmp(), "ffmpeg-f32.wav");
const made = spawnSync(
Expand All @@ -337,12 +331,7 @@ describe("applyVolumeEnvelopeToWav", () => {
);
expect(made.status).toBe(0);

const before = readFileSync(path);
// The format tag is the load-bearing part; the chunk LAYOUT is this
// build's quirk, so it is logged as context rather than required — a
// build emitting a canonical 16-byte fmt with data at 44 is legal and
// handled, and pinning 18/92 would fail on the good case.
expect(before.readUInt16LE(20)).toBe(3); // WAVE_FORMAT_IEEE_FLOAT
expect(readWav(path)).toMatchObject({ float: true, channels: 2, sampleRate: 48000 });

expect(
applyVolumeEnvelopeToWav(
Expand Down Expand Up @@ -376,4 +365,37 @@ describe("applyVolumeEnvelopeToWav", () => {
expect(Math.abs(after.readFloatLE(dataOffset + (SAMPLE_RATE - 2) * 8))).toBeLessThan(0.02);
});
});

// FFmpeg 8.1.1 output, captured without rewriting its RIFF/fmt/fact/LIST/data chunks.
// ffmpeg -f lavfi -i 'aevalsrc=0.5|-0.5|0.25|-0.25:s=48000:d=0.0001' -c:a <codec> out.wav
const extensibleFixtures = [
{
codec: "pcm_f32le",
float: true,
hex: "52494646ba00000057415645666d742028000000feff040080bb000000b80b001000200016002000070100000300000000001000800000aa00389b716661637404000000050000004c4953541a000000494e464f495346540e0000004c61766636322e31322e3130310064617461500000000000003f000000bf0000803e000080be0000003f000000bf0000803e000080be0000003f000000bf0000803e000080be0000003f000000bf0000803e000080be0000003f000000bf0000803e000080be",
},
{
codec: "pcm_s16le",
float: false,
hex: "524946468600000057415645666d742028000000feff040080bb000000dc05000800100016001000070100000100000000001000800000aa00389b714c4953541a000000494e464f495346540e0000004c61766636322e31322e313031006461746128000000004000c0002000e0004000c0002000e0004000c0002000e0004000c0002000e0004000c0002000e0",
},
];

it.each(extensibleFixtures)(
"reads and applies gain to real FFmpeg extensible $codec",
(fixture) => {
const path = join(tmp(), "extensible.wav");
const original = Buffer.from(fixture.hex, "hex");
expect(original.readUInt16LE(20)).toBe(0xfffe);
writeFileSync(path, original);
const decoded = readWav(path);
expect(decoded).toMatchObject({ float: fixture.float, channels: 4, sampleRate: 48000 });
expect([...decoded.samples]).toEqual(Array(5).fill([0.5, -0.5, 0.25, -0.25]).flat());
expect(applyVolumeEnvelopeToWav(path, [{ time: 0, volume: 0.5 }], 0, 1)).toBe(true);
expect([...readWav(path).samples]).toEqual(
Array(5).fill([0.25, -0.25, 0.125, -0.125]).flat(),
);
expect(readFileSync(path).subarray(0, 60)).toEqual(original.subarray(0, 60));
},
);
});
8 changes: 4 additions & 4 deletions packages/engine/src/services/audioVolumeEnvelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ 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";
import { riffChunks, wavFormatTag } from "./wavChunks.js";

const PCM_FORMAT = 1; // WAVE_FORMAT_PCM
const FLOAT_FORMAT = 3; // WAVE_FORMAT_IEEE_FLOAT
Expand Down Expand Up @@ -51,8 +51,8 @@ interface WavFmt {
}

/** The `fmt ` chunk, or null for a format this cannot safely edit in place. */
function readFmtChunk(buffer: Buffer, body: number): WavFmt | null {
const format = buffer.readUInt16LE(body);
function readFmtChunk(buffer: Buffer, body: number, size: number): WavFmt | null {
const format = wavFormatTag(buffer, body, size);
const bits = buffer.readUInt16LE(body + 14);
const float = format === FLOAT_FORMAT;
if (!float && format !== PCM_FORMAT) return null;
Expand All @@ -78,7 +78,7 @@ function parseWavLayout(buffer: Buffer): WavLayout | null {

for (const { id, body, size } of riffChunks(buffer)) {
if (id === "fmt " && body + 16 <= buffer.length) {
fmt = readFmtChunk(buffer, body);
fmt = readFmtChunk(buffer, body, size);
} else if (id === "data") {
data = { offset: body, size: Math.min(size, buffer.length - body) };
}
Expand Down
33 changes: 32 additions & 1 deletion packages/engine/src/services/wavChunks.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { riffChunks } from "./wavChunks.js";
import { riffChunks, wavFormatTag } from "./wavChunks.js";

/**
* The two WAV readers that share this walk both had their own copy, and neither
Expand Down Expand Up @@ -54,3 +54,34 @@ describe("riffChunks", () => {
expect([...riffChunks(buf)].map((c) => c.id)).toEqual(["data"]);
});
});

describe("wavFormatTag", () => {
const floatFmt = Buffer.from(
"feff040080bb000000b80b001000200016002000070100000300000000001000800000aa00389b71",
"hex",
);

it("resolves a complete IEEE float GUID", () => {
expect(wavFormatTag(floatFmt, 0, floatFmt.length)).toBe(3);
});

it("rejects a different GUID sharing the float prefix", () => {
const unknown = Buffer.from(floatFmt);
unknown[39] = 0;
expect(wavFormatTag(unknown, 0, unknown.length)).toBeNull();
});

it("does not read a GUID beyond the declared chunk", () => {
expect(wavFormatTag(floatFmt, 0, 24)).toBeNull();
});

it("rejects a physically truncated GUID", () => {
expect(wavFormatTag(floatFmt.subarray(0, 39), 0, 40)).toBeNull();
});

it.each([0, 21, 23])("rejects an invalid extension length of %i", (size) => {
const malformed = Buffer.from(floatFmt);
malformed.writeUInt16LE(size, 16);
expect(wavFormatTag(malformed, 0, malformed.length)).toBeNull();
});
});
34 changes: 22 additions & 12 deletions packages/engine/src/services/wavChunks.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,4 @@
/**
* The RIFF chunk walk, which two WAV readers in this directory each had a copy
* of: `audioFxRender`'s `readWavChunks` and `audioVolumeEnvelope`'s
* `parseWavLayout`.
*
* Only the walk is shared. What the two do with the chunks is genuinely
* different — one wants a slice of the payload and lets the decoder judge the
* format, the other wants offsets to edit in place and refuses anything that is
* not 16-bit PCM — and folding those together would mean picking one behaviour
* for each difference, in the parser every render's audio passes through. So
* this yields chunks and holds no policy at all.
*/
/** Shared RIFF chunk traversal and WAV format-tag resolution. */

export interface RiffChunk {
/** Four ASCII characters: `fmt `, `data`, `LIST`, `fact`, … */
Expand All @@ -36,3 +25,24 @@ export function* riffChunks(buffer: Buffer): Generator<RiffChunk> {
offset += 8 + size + (size % 2);
}
}

const EXTENSIBLE_FORMATS = new Map([
["0100000000001000800000aa00389b71", 1],
["0300000000001000800000aa00389b71", 3],
]);

/** Resolve WAVE_FORMAT_EXTENSIBLE only for the full PCM and IEEE-float GUIDs. */
function extensibleFormatTag(fmt: Buffer): number | null {
const extraSize = fmt.readUInt16LE(16);
if (extraSize < 22 || 18 + extraSize > fmt.length) return null;
return EXTENSIBLE_FORMATS.get(fmt.toString("hex", 24, 40)) ?? null;
}

/** Canonical codec tag, or null for a truncated or unsupported extensible header. */
export function wavFormatTag(buffer: Buffer, body: number, size: number): number | null {
const fmt = buffer.subarray(body, body + size);
if (fmt.length < 16) return null;
const tag = fmt.readUInt16LE(0);
if (tag !== 0xfffe) return tag;
return fmt.length < 40 ? null : extensibleFormatTag(fmt);
}
Loading