Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1 @@
export { parseWav } from "./wav-riff";

View File

@@ -0,0 +1,65 @@
# wav-riff
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Parses WAV (RIFF WAVE) audio files, extracting both audio stream properties from the `fmt` chunk and text metadata from the `LIST INFO` sub-chunk. WAV files do not have a rich tag format like ID3v2 or Vorbis comments — they rely on the RIFF INFO chunk standard, which provides a small set of fixed-purpose fields sufficient for basic library management.
The parser also reads the `data` chunk size to estimate duration when combined with the sample rate from `fmt`.
## Features
- Verifies "RIFF" + "WAVE" magic before parsing
- Reads `fmt` chunk: channel count, sample rate, byte rate (→ bitrate estimation)
- Reads `data` chunk size for duration estimation (assumes 16-bit PCM)
- Finds `LIST INFO` sub-chunks and maps them to `Track` fields:
- `INAM` → title, `IART` → artist, `IPRD` → album, `IGNR` → genre, `ICMT` → comment, `ICRD` → year
- Handles RIFF chunk padding (chunks padded to even byte boundaries)
- Strips trailing null bytes and whitespace from INFO strings
## Usage: Basic Example
```ts
import { parseWav } from "@suaveplan/audio-metadata/parsers/wav-riff";
const track = parseWav(wavBuffer);
console.log(track.title); // "Sample Track"
console.log(track.sampleRate); // 44100
console.log(track.channels); // 2
```
## Usage: Advanced Example
```ts
import { parseWav } from "@suaveplan/audio-metadata/parsers/wav-riff";
const track = parseWav(wavBuffer);
if (track.bitrate) {
console.log(`Bitrate: ${track.bitrate} kbps`);
}
if (track.duration) {
const mins = Math.floor(track.duration / 60);
const secs = Math.floor(track.duration % 60);
console.log(`Duration: ${mins}:${secs.toString().padStart(2, "0")}`);
}
```
## API Reference
### `parseWav(buffer)`
```ts
function parseWav(buffer: Uint8Array): Track
```
| Parameter | Type | Description |
|-----------|------|-------------|
| `buffer` | `Uint8Array` | Full WAV file buffer |
**Returns:** `Track` object with `format: "wav"`. Returns `{}` if the buffer does not start with "RIFF" + "WAVE".
## Implementation Notes
RIFF uses little-endian byte ordering throughout, unlike the big-endian conventions in AIFF and MP4. Chunk sizes in RIFF do not include the 8-byte header (4-byte ID + 4-byte size), so the data starts at `offset + 8`. Chunks are always padded to even byte boundaries — the walker adds `size % 2` when advancing to the next chunk. Duration estimation assumes 16-bit PCM (`bytesPerSample = 2`) because the `fmt` chunk is needed to determine actual bits-per-sample, and reading it correctly requires parsing the complete PCM/non-PCM format discriminator. For compressed WAV formats the duration estimate will be inaccurate, but the sample rate and channel count will still be correct.

View File

@@ -0,0 +1,355 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseWav } from "./wav-riff.js";
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
function uint32LE(n: number): number[] {
return [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff];
}
function uint16LE(n: number): number[] {
return [n & 0xff, (n >> 8) & 0xff];
}
function riffChunk(id: string, data: number[]): number[] {
const idBytes = Array.from(new TextEncoder().encode(id));
const paddedSize = data.length + (data.length % 2); // pad to even
const padded = [...data];
if (data.length % 2 !== 0) padded.push(0);
return [
...idBytes.slice(0, 4),
...uint32LE(data.length),
...padded.slice(0, paddedSize),
];
}
function infoChunk(id: string, value: string): number[] {
const valBytes = [...Array.from(new TextEncoder().encode(value)), 0x00]; // null terminated
return riffChunk(id, valBytes);
}
function listInfoChunk(infoChunks: number[]): number[] {
const typeBytes = Array.from(new TextEncoder().encode("INFO"));
return riffChunk("LIST", [...typeBytes, ...infoChunks]);
}
function fmtChunk(channels: number, sampleRate: number): number[] {
const byteRate = sampleRate * channels * 2; // 16-bit PCM
const blockAlign = channels * 2;
return riffChunk("fmt ", [
...uint16LE(1), // PCM format
...uint16LE(channels),
...uint32LE(sampleRate),
...uint32LE(byteRate),
...uint16LE(blockAlign),
...uint16LE(16), // bits per sample
]);
}
function buildWavFile(extraChunks: number[][]): Uint8Array {
const waveBytes = Array.from(new TextEncoder().encode("WAVE"));
const allChunks = extraChunks.flat();
const riffSize = 4 + allChunks.length; // "WAVE" + chunks
const header = [
0x52,
0x49,
0x46,
0x46, // "RIFF"
...uint32LE(riffSize),
...waveBytes,
...allChunks,
];
return new Uint8Array(header);
}
describe("wav-riff parser", () => {
it("parses title from LIST INFO INAM", { timeout: 5000 }, () => {
const buf = buildWavFile([
fmtChunk(2, 44100),
listInfoChunk(infoChunk("INAM", "Test WAV Title")),
]);
const track = parseWav(buf);
expect(track.title).toBe("Test WAV Title");
expect(track.format).toBe("wav");
});
it("parses artist from IART", { timeout: 5000 }, () => {
const buf = buildWavFile([
listInfoChunk(infoChunk("IART", "WAV Artist")),
]);
const track = parseWav(buf);
expect(track.artist).toBe("WAV Artist");
});
it("parses album from IPRD", { timeout: 5000 }, () => {
const buf = buildWavFile([
listInfoChunk(infoChunk("IPRD", "WAV Album")),
]);
const track = parseWav(buf);
expect(track.album).toBe("WAV Album");
});
it("parses year from ICRD", { timeout: 5000 }, () => {
const buf = buildWavFile([
listInfoChunk(infoChunk("ICRD", "2020-01-15")),
]);
const track = parseWav(buf);
expect(track.year).toBe(2020);
});
it("parses genre from IGNR", { timeout: 5000 }, () => {
const buf = buildWavFile([
listInfoChunk(infoChunk("IGNR", "Classical")),
]);
const track = parseWav(buf);
expect(track.genre).toBe("Classical");
});
it("parses comment from ICMT", { timeout: 5000 }, () => {
const buf = buildWavFile([
listInfoChunk(infoChunk("ICMT", "Nice WAV")),
]);
const track = parseWav(buf);
expect(track.comment).toBe("Nice WAV");
});
it("reads sample rate and channels from fmt chunk", {
timeout: 5000,
}, () => {
const buf = buildWavFile([fmtChunk(2, 44100)]);
const track = parseWav(buf);
expect(track.sampleRate).toBe(44100);
expect(track.channels).toBe(2);
});
it("returns empty object for non-WAV buffer", { timeout: 5000 }, () => {
expect(
parseWav(
new Uint8Array([
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09,
0x0a, 0x0b,
])
)
).toEqual({});
});
it("returns empty object for short buffer", { timeout: 5000 }, () => {
expect(parseWav(new Uint8Array([0x52, 0x49, 0x46, 0x46]))).toEqual({});
});
it("returns empty object for RIFF without WAVE", { timeout: 5000 }, () => {
const buf = new Uint8Array([
0x52,
0x49,
0x46,
0x46,
0x04,
0x00,
0x00,
0x00,
0x41,
0x56,
0x49,
0x20, // "AVI " not WAVE
]);
expect(parseWav(buf)).toEqual({});
});
it("stores rawTags", { timeout: 5000 }, () => {
const buf = buildWavFile([listInfoChunk(infoChunk("INAM", "Tagged"))]);
const track = parseWav(buf);
expect(track.rawTags?.["INAM"]).toBe("Tagged");
});
it("calculates duration from data chunk and fmt", { timeout: 5000 }, () => {
// 44100 samples at 16-bit stereo = 44100 * 4 bytes/frame
const numSamples = 44100; // 1 second
const numBytes = numSamples * 2 * 2; // 16-bit stereo
const dataChunk = riffChunk("data", new Array(numBytes).fill(0));
const buf = buildWavFile([fmtChunk(2, 44100), dataChunk]);
const track = parseWav(buf);
expect(track.sampleRate).toBe(44100);
expect(track.channels).toBe(2);
expect(track.duration).toBeCloseTo(1.0, 1);
});
it("computes bitrate from fmt byteRate", { timeout: 5000 }, () => {
const buf = buildWavFile([fmtChunk(2, 44100)]);
const track = parseWav(buf);
// byteRate = 44100 * 2 * 2 = 176400, bitrate = round(176400 * 8 / 1000) = 1411
expect(track.bitrate).toBe(1411);
});
it("handles truncated fmt chunk gracefully (size < 16 — skips content)", {
timeout: 5000,
}, () => {
// Build a RIFF/WAVE with a fmt chunk that has size < 16
// This triggers the FALSE branch of `if (chunk.size >= 16)`
const uint32LE = (n: number) => [
n & 0xff,
(n >> 8) & 0xff,
(n >> 16) & 0xff,
(n >> 24) & 0xff,
];
// fmt chunk with only 8 bytes of data (size=8 < 16)
const fmtData = [0x01, 0x00, 0x02, 0x00, 0x44, 0xac, 0x00, 0x00];
const fmtChunkBytes = [
0x66,
0x6d,
0x74,
0x20, // "fmt "
...uint32LE(8), // size = 8 (less than 16)
...fmtData,
];
const riffSize = 4 + fmtChunkBytes.length;
const buf = new Uint8Array([
0x52,
0x49,
0x46,
0x46,
...uint32LE(riffSize),
0x57,
0x41,
0x56,
0x45,
...fmtChunkBytes,
]);
const track = parseWav(buf);
expect(track.format).toBe("wav");
// channels/sampleRate not set (fmt too small)
expect(track.channels).toBeUndefined();
});
it("handles fmt chunk where channel bytes are at buffer boundary", {
timeout: 5000,
}, () => {
// Build a RIFF/WAVE with a fmt chunk that claims size=16 but only has 2 bytes of data.
// This triggers the ??(0) fallbacks for channel/sampleRate bytes beyond buffer
const uint32LE = (n: number) => [
n & 0xff,
(n >> 8) & 0xff,
(n >> 16) & 0xff,
(n >> 24) & 0xff,
];
// Only 2 bytes of real data after the 8-byte id+size header
const fmtChunkBytes = [
0x66,
0x6d,
0x74,
0x20, // "fmt "
...uint32LE(16), // claims 16 bytes
0x01,
0x00, // only 2 bytes — everything else out of bounds
];
const riffSize = 4 + fmtChunkBytes.length;
const buf = new Uint8Array([
0x52,
0x49,
0x46,
0x46,
...uint32LE(riffSize),
0x57,
0x41,
0x56,
0x45,
...fmtChunkBytes,
]);
const track = parseWav(buf);
expect(track.format).toBe("wav");
});
it("ignores non-INFO LIST chunk", { timeout: 5000 }, () => {
// Build a LIST chunk with type "adtl" (not "INFO")
const adtlTypeBytes = Array.from(new TextEncoder().encode("adtl"));
const listData = [...adtlTypeBytes, 0x00, 0x00, 0x00, 0x00];
const listChunk = riffChunk("LIST", listData);
const buf = buildWavFile([fmtChunk(2, 44100), listChunk]);
const track = parseWav(buf);
// No tags — title/artist should be undefined
expect(track.title).toBeUndefined();
expect(track.format).toBe("wav");
});
it("skips empty INFO values", { timeout: 5000 }, () => {
// INAM with only null bytes — after trim should be empty
const emptyNameChunk = riffChunk("INAM", [0x00]);
const listChunk = listInfoChunk(emptyNameChunk);
const buf = buildWavFile([fmtChunk(2, 44100), listChunk]);
const track = parseWav(buf);
// empty value should not be assigned
expect(track.title).toBeUndefined();
});
it("handles data chunk without prior fmt chunk (no duration)", {
timeout: 5000,
}, () => {
// data chunk before fmt chunk — duration calc requires sampleRate to be set first
const numBytes = 1000;
const dataChunk = riffChunk("data", new Array(numBytes).fill(0));
const buf = buildWavFile([dataChunk, fmtChunk(2, 44100)]);
const track = parseWav(buf);
// fmt comes after data so when data is processed sampleRate may not be set
// (depends on iteration order — this tests the branch where sampleRate is not yet known)
expect(track.format).toBe("wav");
});
it("skips LIST chunk when data is truncated at boundary", {
timeout: 5000,
}, () => {
// Build a RIFF header that claims there's a LIST chunk but only 3 bytes of data (< 4)
// This exercises the `chunk.dataOffset + 4 > buffer.length` check
const uint32LE = (n: number) => [
n & 0xff,
(n >> 8) & 0xff,
(n >> 16) & 0xff,
(n >> 24) & 0xff,
];
// LIST chunk: id="LIST" + size=3 (meaning 3 bytes of data)
const listChunk = [
0x4c,
0x49,
0x53,
0x54, // "LIST"
...uint32LE(3), // size=3 (fewer than 4 bytes needed for type)
0x49,
0x4e,
0x46, // 3 bytes of data (incomplete type)
];
const riffSize = 4 + listChunk.length;
const buf = new Uint8Array([
0x52,
0x49,
0x46,
0x46,
...uint32LE(riffSize),
0x57,
0x41,
0x56,
0x45,
...listChunk,
]);
const track = parseWav(buf);
expect(track.format).toBe("wav");
expect(track.title).toBeUndefined();
});
it("skips empty ICRD date string", { timeout: 5000 }, () => {
// ICRD with only null bytes — after trim should be empty, year should not be set
const emptyIcrdChunk = riffChunk("ICRD", [0x00]);
const listChunk = listInfoChunk(emptyIcrdChunk);
const buf = buildWavFile([fmtChunk(2, 44100), listChunk]);
const track = parseWav(buf);
expect(track.year).toBeUndefined();
});
it("ignores non-numeric ICRD year", { timeout: 5000 }, () => {
// ICRD with non-numeric date — parseInt returns NaN, year should not be set
const nonNumericIcrd = infoChunk("ICRD", "abcd-01-01");
const listChunk = listInfoChunk(nonNumericIcrd);
const buf = buildWavFile([fmtChunk(2, 44100), listChunk]);
const track = parseWav(buf);
expect(track.year).toBeUndefined();
});
});

View File

@@ -0,0 +1,163 @@
import type { Track } from "../../types/metadata.types.js";
import { readString, readUint32LE } from "../../utils/buffer-utils.js";
// ---------------------------------------------------------------------------
// RIFF chunk walker
// ---------------------------------------------------------------------------
interface Chunk {
id: string;
size: number;
dataOffset: number;
}
/**
* Iterate RIFF sub-chunks within [start, end).
* Each chunk: 4-byte ASCII id + 4-byte little-endian size + data.
* Chunks are padded to even byte boundaries.
*/
function* iterChunks(
buf: Uint8Array,
start: number,
end: number
): Generator<Chunk> {
let pos = start;
while (pos + 8 <= end) {
const id = readString(buf, pos, 4, "latin1");
const size = readUint32LE(buf, pos + 4);
yield { id, size, dataOffset: pos + 8 };
pos += 8 + size + (size % 2); // pad to even
}
}
// ---------------------------------------------------------------------------
// INFO chunk tag mappings
// ---------------------------------------------------------------------------
const INFO_MAP: Record<
string,
keyof Pick<Track, "title" | "artist" | "album" | "genre" | "comment">
> = {
INAM: "title",
IART: "artist",
IPRD: "album",
IGNR: "genre",
ICMT: "comment",
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Parse WAV/RIFF metadata from a LIST INFO chunk.
*
* WAV files use a RIFF container. Metadata is stored in a LIST chunk whose
* first four data bytes are "INFO". Inside, each sub-chunk maps to a tag:
* INAM → title, IART → artist, IPRD → album, ICRD → year, IGNR → genre,
* ICMT → comment.
*
* Returns a normalised `Track`. Returns an empty object if the buffer
* does not start with "RIFF" + "WAVE".
*/
export function parseWav(buffer: Uint8Array): Track {
if (buffer.length < 12) return {};
if (
buffer[0] !== 0x52 || // R
buffer[1] !== 0x49 || // I
buffer[2] !== 0x46 || // F
buffer[3] !== 0x46 // F
) {
return {};
}
if (
buffer[8] !== 0x57 || // W
buffer[9] !== 0x41 || // A
buffer[10] !== 0x56 || // V
buffer[11] !== 0x45 // E
) {
return {};
}
const riffSize = readUint32LE(buffer, 4);
const end = Math.min(12 + riffSize - 4, buffer.length); // -4 because riffSize includes "WAVE"
const raw: Record<string, string | string[]> = {};
const track: Track = { format: "wav" };
// Also extract fmt chunk for audio properties
for (const chunk of iterChunks(buffer, 12, end)) {
if (chunk.id === "fmt ") {
// PCM: audio format(2LE), channels(2LE), sampleRate(4LE), byteRate(4LE), blockAlign(2LE), bitsPerSample(2LE)
if (chunk.size >= 16) {
const channels =
(buffer[chunk.dataOffset + 2] ?? 0) |
((buffer[chunk.dataOffset + 3] ?? 0) << 8);
const sampleRate = readUint32LE(buffer, chunk.dataOffset + 4);
const byteRate = readUint32LE(buffer, chunk.dataOffset + 8);
if (channels > 0) track.channels = channels;
if (sampleRate > 0) track.sampleRate = sampleRate;
if (byteRate > 0)
track.bitrate = Math.round((byteRate * 8) / 1000);
}
}
if (chunk.id === "data") {
if (track.sampleRate && track.channels) {
const bitsPerSample = 16; // Assume 16-bit PCM if not otherwise known
const bytesPerSample = bitsPerSample / 8;
// track.channels is truthy here (checked in the if above)
const totalSamples =
chunk.size / (bytesPerSample * (track.channels as number));
track.duration = totalSamples / track.sampleRate;
}
}
if (chunk.id !== "LIST") continue;
if (chunk.dataOffset + 4 > buffer.length) continue;
const listType = readString(buffer, chunk.dataOffset, 4, "latin1");
if (listType !== "INFO") continue;
for (const infoChunk of iterChunks(
buffer,
chunk.dataOffset + 4,
chunk.dataOffset + chunk.size
)) {
const fieldKey = INFO_MAP[infoChunk.id];
if (fieldKey) {
const val = readString(
buffer,
infoChunk.dataOffset,
infoChunk.size,
"latin1"
)
.replace(/\0+$/, "")
.trim();
if (val) {
track[fieldKey] = val;
raw[infoChunk.id] = val;
}
}
if (infoChunk.id === "ICRD") {
const dateStr = readString(
buffer,
infoChunk.dataOffset,
infoChunk.size,
"latin1"
)
.replace(/\0+$/, "")
.trim();
if (dateStr) {
raw["ICRD"] = dateStr;
const y = Number.parseInt(dateStr.substring(0, 4), 10);
if (!Number.isNaN(y)) track.year = y;
}
}
}
}
track.rawTags = raw;
return track;
}