From 36feff2ebb8f27ef0a4226edb5c1431f432fb77a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Mon, 21 Sep 2026 23:14:09 -0400 Subject: [PATCH] fix(engine): read extensible PCM and float WAV headers --- packages/engine/src/services/audioFxRender.ts | 6 ++- .../src/services/audioVolumeEnvelope.test.ts | 50 +++++++++++++------ .../src/services/audioVolumeEnvelope.ts | 8 +-- .../engine/src/services/wavChunks.test.ts | 33 +++++++++++- packages/engine/src/services/wavChunks.ts | 34 ++++++++----- 5 files changed, 98 insertions(+), 33 deletions(-) diff --git a/packages/engine/src/services/audioFxRender.ts b/packages/engine/src/services/audioFxRender.ts index 8c977d4fb6..1f97d2bf3f 100644 --- a/packages/engine/src/services/audioFxRender.ts +++ b/packages/engine/src/services/audioFxRender.ts @@ -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 { @@ -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); diff --git a/packages/engine/src/services/audioVolumeEnvelope.test.ts b/packages/engine/src/services/audioVolumeEnvelope.test.ts index 970e260b4e..e11d28058f 100644 --- a/packages/engine/src/services/audioVolumeEnvelope.test.ts +++ b/packages/engine/src/services/audioVolumeEnvelope.test.ts @@ -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) => { @@ -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( @@ -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( @@ -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 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)); + }, + ); }); diff --git a/packages/engine/src/services/audioVolumeEnvelope.ts b/packages/engine/src/services/audioVolumeEnvelope.ts index 5fc83306e4..f7459f2e25 100644 --- a/packages/engine/src/services/audioVolumeEnvelope.ts +++ b/packages/engine/src/services/audioVolumeEnvelope.ts @@ -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 @@ -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; @@ -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) }; } diff --git a/packages/engine/src/services/wavChunks.test.ts b/packages/engine/src/services/wavChunks.test.ts index 6ee943203a..eb1fa47109 100644 --- a/packages/engine/src/services/wavChunks.test.ts +++ b/packages/engine/src/services/wavChunks.test.ts @@ -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 @@ -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(); + }); +}); diff --git a/packages/engine/src/services/wavChunks.ts b/packages/engine/src/services/wavChunks.ts index 9265141386..b6db2521de 100644 --- a/packages/engine/src/services/wavChunks.ts +++ b/packages/engine/src/services/wavChunks.ts @@ -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`, … */ @@ -36,3 +25,24 @@ export function* riffChunks(buffer: Buffer): Generator { 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); +}