Skip to content

Commit dd9ec94

Browse files
committed
refactor(engine): one RIFF chunk walk, not two
`readWavChunks` and `parseWavLayout` each carried the same word-aligned walk. The reason this was left alone before is real — the two are *not* the same function, and unifying them means picking one behaviour for each of four differences in the parser every render's audio goes through: one breaks at the first `data` and returns a slice, the other scans every chunk and returns offsets; one lets the decoder judge the format, the other refuses anything but 16-bit PCM. So only the walk moves. `riffChunks` yields `{ id, body, size }` and holds no policy at all, both readers keep every one of those four behaviours, and there is no cycle to route around because it belongs to neither of them. It lives in engine/services beside both. It also had no test, in either copy. A real WAV out of the mixer has an even-sized `fmt ` first and `data` last, so neither of the two things the walk exists for ever came up: the pad byte after an odd chunk, and not assuming an ordering. Both mutations survived a full engine run before `wavChunks.test.ts`; both fail now, as does removing the guard against a header that runs past a truncated file. engine services 739 passing / 3 skipped (736 + the 3 new).
1 parent 1b820e6 commit dd9ec94

4 files changed

Lines changed: 110 additions & 20 deletions

File tree

packages/engine/src/services/audioFxRender.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { enabledAudioFxNodes, type HfAudioFxChain } from "@hyperframes/core/audi
2121
import { serializeAutomation, type HfAutomation } from "@hyperframes/core/audio-automation";
2222
import { acquireBrowser } from "./browserManager.js";
2323
import { createEnvelopeWalker } from "./audioVolumeEnvelope.js";
24+
import { riffChunks } from "./wavChunks.js";
2425
import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
2526

2627
export class AudioFxRenderError extends Error {
@@ -49,22 +50,22 @@ function readWavChunks(buf: Buffer): {
4950
bits: number;
5051
data?: Buffer;
5152
} {
52-
let offset = 12;
5353
const head = { format: 1, channels: 1, sampleRate: 48000, bits: 16 };
5454
let data: Buffer | undefined;
55-
while (offset + 8 <= buf.length) {
56-
const id = buf.toString("ascii", offset, offset + 4);
57-
const size = buf.readUInt32LE(offset + 4);
55+
for (const { id, body, size } of riffChunks(buf)) {
5856
if (id === "fmt ") {
59-
head.format = buf.readUInt16LE(offset + 8);
60-
head.channels = buf.readUInt16LE(offset + 10);
61-
head.sampleRate = buf.readUInt32LE(offset + 12);
62-
head.bits = buf.readUInt16LE(offset + 22);
57+
head.format = buf.readUInt16LE(body);
58+
head.channels = buf.readUInt16LE(body + 2);
59+
head.sampleRate = buf.readUInt32LE(body + 4);
60+
head.bits = buf.readUInt16LE(body + 14);
6361
} else if (id === "data") {
64-
data = buf.subarray(offset + 8, Math.min(buf.length, offset + 8 + size));
62+
data = buf.subarray(body, Math.min(buf.length, body + size));
63+
// The payload is the rest of the file for anything the mixer writes, and
64+
// reading past it buys nothing: `fmt ` precedes `data` in every WAV these
65+
// steps produce, and the alternative is walking a several-hundred-megabyte
66+
// tail chunk by chunk.
6567
break;
6668
}
67-
offset += 8 + size + (size % 2);
6869
}
6970
return { ...head, data };
7071
}

packages/engine/src/services/audioVolumeEnvelope.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { readFileSync, renameSync, writeFileSync } from "fs";
1919
import { randomBytes } from "crypto";
2020
import type { AudioVolumeKeyframe } from "./audioMixer.types.js";
2121
import { normaliseEnvelope } from "@hyperframes/core/media-volume-envelope";
22+
import { riffChunks } from "./wavChunks.js";
2223

2324
const PCM_FORMAT = 1; // WAVE_FORMAT_PCM
2425
const SUPPORTED_BITS = 16;
@@ -42,26 +43,20 @@ function parseWavLayout(buffer: Buffer): WavLayout | null {
4243
if (buffer.length < 12 || buffer.toString("ascii", 0, 4) !== "RIFF") return null;
4344
if (buffer.toString("ascii", 8, 12) !== "WAVE") return null;
4445

45-
let offset = 12;
4646
let fmt: { numChannels: number; sampleRate: number; bitsPerSample: number } | null = null;
4747
let data: { offset: number; size: number } | null = null;
4848

49-
while (offset + 8 <= buffer.length) {
50-
const chunkId = buffer.toString("ascii", offset, offset + 4);
51-
const chunkSize = buffer.readUInt32LE(offset + 4);
52-
const body = offset + 8;
53-
if (chunkId === "fmt " && body + 16 <= buffer.length) {
49+
for (const { id, body, size } of riffChunks(buffer)) {
50+
if (id === "fmt " && body + 16 <= buffer.length) {
5451
if (buffer.readUInt16LE(body) !== PCM_FORMAT) return null;
5552
fmt = {
5653
numChannels: buffer.readUInt16LE(body + 2),
5754
sampleRate: buffer.readUInt32LE(body + 4),
5855
bitsPerSample: buffer.readUInt16LE(body + 14),
5956
};
60-
} else if (chunkId === "data") {
61-
data = { offset: body, size: Math.min(chunkSize, buffer.length - body) };
57+
} else if (id === "data") {
58+
data = { offset: body, size: Math.min(size, buffer.length - body) };
6259
}
63-
// Chunks are word-aligned: an odd size carries a trailing pad byte.
64-
offset = body + chunkSize + (chunkSize % 2);
6560
}
6661

6762
if (!fmt || !data) return null;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import { riffChunks } from "./wavChunks.js";
3+
4+
/**
5+
* The two WAV readers that share this walk both had their own copy, and neither
6+
* had a test for the walk itself — a real WAV out of the mixer has an even-sized
7+
* `fmt ` first and `data` last, so the two things the walk exists to handle
8+
* (ordering, and the pad byte after an odd chunk) never came up in either suite.
9+
*/
10+
function riff(chunks: { id: string; body: Buffer }[]): Buffer {
11+
const parts: Buffer[] = [Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("WAVE")];
12+
for (const { id, body } of chunks) {
13+
const header = Buffer.alloc(8);
14+
header.write(id, 0, "ascii");
15+
header.writeUInt32LE(body.length, 4);
16+
parts.push(header, body);
17+
// Word alignment: an odd body is followed by a pad byte the size excludes.
18+
if (body.length % 2) parts.push(Buffer.alloc(1));
19+
}
20+
const buf = Buffer.concat(parts);
21+
buf.writeUInt32LE(buf.length - 8, 4);
22+
return buf;
23+
}
24+
25+
describe("riffChunks", () => {
26+
it("steps over the pad byte after an odd-sized chunk", () => {
27+
// An odd LIST is what ffmpeg writes for a metadata string of odd length. Read
28+
// without the pad, every chunk after it is one byte out and reads as garbage.
29+
const buf = riff([
30+
{ id: "LIST", body: Buffer.from("INFOodd") },
31+
{ id: "data", body: Buffer.from([1, 2, 3, 4]) },
32+
]);
33+
const found = [...riffChunks(buf)];
34+
expect(found.map((c) => c.id)).toEqual(["LIST", "data"]);
35+
const data = found[1];
36+
if (!data) throw new Error("no data chunk");
37+
expect(buf.subarray(data.body, data.body + data.size)).toEqual(Buffer.from([1, 2, 3, 4]));
38+
});
39+
40+
it("yields chunks in file order, whatever that order is", () => {
41+
// `data` before `fmt ` is legal and the reason the walk advances by declared
42+
// size rather than assuming a layout.
43+
const buf = riff([
44+
{ id: "data", body: Buffer.alloc(6) },
45+
{ id: "fmt ", body: Buffer.alloc(16) },
46+
]);
47+
expect([...riffChunks(buf)].map((c) => c.id)).toEqual(["data", "fmt "]);
48+
});
49+
50+
it("stops at a chunk header that runs past the end of the file", () => {
51+
// Truncated downloads and interrupted writes both land here; the walk must
52+
// end rather than read off the buffer.
53+
const buf = Buffer.concat([riff([{ id: "data", body: Buffer.alloc(4) }]), Buffer.from("da")]);
54+
expect([...riffChunks(buf)].map((c) => c.id)).toEqual(["data"]);
55+
});
56+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* The RIFF chunk walk, which two WAV readers in this directory each had a copy
3+
* of: `audioFxRender`'s `readWavChunks` and `audioVolumeEnvelope`'s
4+
* `parseWavLayout`.
5+
*
6+
* Only the walk is shared. What the two do with the chunks is genuinely
7+
* different — one wants a slice of the payload and lets the decoder judge the
8+
* format, the other wants offsets to edit in place and refuses anything that is
9+
* not 16-bit PCM — and folding those together would mean picking one behaviour
10+
* for each difference, in the parser every render's audio passes through. So
11+
* this yields chunks and holds no policy at all.
12+
*/
13+
14+
export interface RiffChunk {
15+
/** Four ASCII characters: `fmt `, `data`, `LIST`, `fact`, … */
16+
id: string;
17+
/** Byte offset of the chunk's body, past the 8-byte header. */
18+
body: number;
19+
/** The size the chunk declares. May run past the end of a truncated file. */
20+
size: number;
21+
}
22+
23+
/**
24+
* Every chunk after the 12-byte RIFF header, in the order they sit.
25+
*
26+
* Advances by each chunk's declared size, so ordering is not assumed — `data`
27+
* may precede `fmt `, and trailing LIST/fact chunks are walked past rather than
28+
* tripped over. Chunks are word-aligned, so an odd size carries a pad byte.
29+
*/
30+
export function* riffChunks(buffer: Buffer): Generator<RiffChunk> {
31+
let offset = 12;
32+
while (offset + 8 <= buffer.length) {
33+
const id = buffer.toString("ascii", offset, offset + 4);
34+
const size = buffer.readUInt32LE(offset + 4);
35+
yield { id, body: offset + 8, size };
36+
offset += 8 + size + (size % 2);
37+
}
38+
}

0 commit comments

Comments
 (0)