Initial Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { parseWav } from "./wav-riff";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user